Merge branch '7883_new_url_structure' into 7883_frontend_step2

This commit is contained in:
Marcus Efraimsson 2018-02-01 11:08:39 +01:00
commit ef90b3e49c
137 changed files with 3945 additions and 1596 deletions

View File

@ -3,6 +3,7 @@ title = "Provisioning"
description = ""
keywords = ["grafana", "provisioning"]
type = "docs"
aliases = ["/installation/provisioning"]
[menu.docs]
parent = "admin"
weight = 8
@ -66,7 +67,6 @@ Tool | Project
-----|------------
Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)

View File

@ -18,7 +18,7 @@ other domains including industrial sensors, home automation, weather, and proces
- [Installing on Mac OS X](installation/mac)
- [Installing on Windows](installation/windows)
- [Installing on Docker](installation/docker)
- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](installation/provisioning)
- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](administration/provisioning#configuration-management-tools)
- [Nightly Builds](https://grafana.com/grafana/download)
For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}})

View File

@ -68,6 +68,7 @@
"karma-webpack": "^2.0.4",
"lint-staged": "^6.0.0",
"load-grunt-tasks": "3.5.2",
"mobx-react-devtools": "^4.2.15",
"mocha": "^4.0.1",
"ng-annotate-loader": "^0.6.1",
"ng-annotate-webpack-plugin": "^0.2.1-pre",
@ -91,7 +92,7 @@
"typescript": "^2.6.2",
"webpack": "^3.10.0",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1",
"webpack-cleanup-plugin": "^0.5.1",
"webpack-merge": "^4.1.0",
"zone.js": "^0.7.2"
},
@ -148,8 +149,8 @@
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-grid-layout": "^0.16.2",
"react-popper": "^0.7.5",
"react-highlight-words": "^0.10.0",
"react-popper": "^0.7.5",
"react-select": "^1.1.0",
"react-sizeme": "^2.3.6",
"remarkable": "^1.7.1",

View File

@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/guardian"
)
func ValidateOrgAlert(c *middleware.Context) {
@ -62,9 +63,22 @@ func GetAlerts(c *middleware.Context) Response {
return ApiError(500, "List alerts failed", err)
}
alertDTOs, resp := transformToDTOs(query.Result, c)
if resp != nil {
return resp
}
return Json(200, alertDTOs)
}
func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) {
if len(alerts) == 0 {
return []*dtos.AlertRule{}, nil
}
dashboardIds := make([]int64, 0)
alertDTOs := make([]*dtos.AlertRule, 0)
for _, alert := range query.Result {
for _, alert := range alerts {
dashboardIds = append(dashboardIds, alert.DashboardId)
alertDTOs = append(alertDTOs, &dtos.AlertRule{
Id: alert.Id,
@ -83,10 +97,8 @@ func GetAlerts(c *middleware.Context) Response {
DashboardIds: dashboardIds,
}
if len(alertDTOs) > 0 {
if err := bus.Dispatch(&dashboardsQuery); err != nil {
return ApiError(500, "List alerts failed", err)
}
if err := bus.Dispatch(&dashboardsQuery); err != nil {
return nil, ApiError(500, "List alerts failed", err)
}
//TODO: should be possible to speed this up with lookup table
@ -98,7 +110,26 @@ func GetAlerts(c *middleware.Context) Response {
}
}
return Json(200, alertDTOs)
permissionsQuery := models.GetDashboardPermissionsForUserQuery{
DashboardIds: dashboardIds,
OrgId: c.OrgId,
UserId: c.SignedInUser.UserId,
OrgRole: c.SignedInUser.OrgRole,
}
if err := bus.Dispatch(&permissionsQuery); err != nil {
return nil, ApiError(500, "List alerts failed", err)
}
for _, alert := range alertDTOs {
for _, perm := range permissionsQuery.Result {
if alert.DashboardId == perm.DashboardId {
alert.CanEdit = perm.Permission > 1
}
}
}
return alertDTOs, nil
}
// POST /api/alerts/test
@ -155,24 +186,6 @@ func GetAlert(c *middleware.Context) Response {
return Json(200, &query.Result)
}
// DEL /api/alerts/:id
func DelAlert(c *middleware.Context) Response {
alertId := c.ParamsInt64(":alertId")
if alertId == 0 {
return ApiError(401, "Failed to parse alertid", nil)
}
cmd := models.DeleteAlertCommand{AlertId: alertId}
if err := bus.Dispatch(&cmd); err != nil {
return ApiError(500, "Failed to delete alert", err)
}
var resp = map[string]interface{}{"alertId": alertId}
return Json(200, resp)
}
func GetAlertNotifiers(c *middleware.Context) Response {
return Json(200, alerting.GetNotifiers())
}
@ -267,6 +280,22 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R
//POST /api/alerts/:alertId/pause
func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
alertId := c.ParamsInt64("alertId")
query := models.GetAlertByIdQuery{Id: alertId}
if err := bus.Dispatch(&query); err != nil {
return ApiError(500, "Get Alert failed", err)
}
guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser)
if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
if err != nil {
return ApiError(500, "Error while checking permissions for Alert", err)
}
return ApiError(403, "Access denied to this dashboard and alert", nil)
}
cmd := models.PauseAlertCommand{
OrgId: c.OrgId,
AlertIds: []int64{alertId},

97
pkg/api/alerting_test.go Normal file
View File

@ -0,0 +1,97 @@
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/smartystreets/goconvey/convey"
)
func TestAlertingApiEndpoint(t *testing.T) {
Convey("Given an alert in a dashboard with an acl", t, func() {
singleAlert := &m.Alert{Id: 1, DashboardId: 1, Name: "singlealert"}
bus.AddHandler("test", func(query *m.GetAlertByIdQuery) error {
query.Result = singleAlert
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
})
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = []*m.Team{}
return nil
})
Convey("When user is editor and not in the ACL", func() {
Convey("Should not be able to pause the alert", func() {
cmd := dtos.PauseAlertCommand{
AlertId: 1,
Paused: true,
}
postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
CallPauseAlert(sc)
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 pause the alert", func() {
cmd := dtos.PauseAlertCommand{
AlertId: 1,
Paused: true,
}
postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
CallPauseAlert(sc)
So(sc.resp.Code, ShouldEqual, 200)
})
})
})
})
}
func CallPauseAlert(sc *scenarioContext) {
bus.AddHandler("test", func(cmd *m.PauseAlertCommand) error {
return nil
})
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
func postAlertScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PauseAlertCommand, 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.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
return PauseAlert(c, cmd)
})
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}

View File

@ -7,7 +7,9 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/util"
)
@ -51,6 +53,10 @@ func (e *CreateAnnotationError) Error() string {
}
func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response {
if canSave, err := canSaveByDashboardId(c, cmd.DashboardId); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
repo := annotations.GetRepository()
if cmd.Text == "" {
@ -178,6 +184,10 @@ func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Resp
repo := annotations.GetRepository()
if resp := canSave(c, repo, annotationId); resp != nil {
return resp
}
item := annotations.Item{
OrgId: c.OrgId,
UserId: c.UserId,
@ -228,6 +238,10 @@ func DeleteAnnotationById(c *middleware.Context) Response {
repo := annotations.GetRepository()
annotationId := c.ParamsInt64(":annotationId")
if resp := canSave(c, repo, annotationId); resp != nil {
return resp
}
err := repo.Delete(&annotations.DeleteParams{
Id: annotationId,
})
@ -243,6 +257,10 @@ func DeleteAnnotationRegion(c *middleware.Context) Response {
repo := annotations.GetRepository()
regionId := c.ParamsInt64(":regionId")
if resp := canSave(c, repo, regionId); resp != nil {
return resp
}
err := repo.Delete(&annotations.DeleteParams{
RegionId: regionId,
})
@ -253,3 +271,50 @@ func DeleteAnnotationRegion(c *middleware.Context) Response {
return ApiSuccess("Annotation region deleted")
}
func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error) {
if dashboardId == 0 && !c.SignedInUser.HasRole(m.ROLE_EDITOR) {
return false, nil
}
if dashboardId > 0 {
guardian := guardian.NewDashboardGuardian(dashboardId, c.OrgId, c.SignedInUser)
if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
return false, err
}
}
return true, nil
}
func canSave(c *middleware.Context, repo annotations.Repository, annotationId int64) Response {
items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationId, OrgId: c.OrgId})
if err != nil || len(items) == 0 {
return ApiError(500, "Could not find annotation to update", err)
}
dashboardId := items[0].DashboardId
if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
return nil
}
func canSaveByRegionId(c *middleware.Context, repo annotations.Repository, regionId int64) Response {
items, err := repo.Find(&annotations.ItemQuery{RegionId: regionId, OrgId: c.OrgId})
if err != nil || len(items) == 0 {
return ApiError(500, "Could not find annotation to update", err)
}
dashboardId := items[0].DashboardId
if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
return nil
}

242
pkg/api/annotations_test.go Normal file
View File

@ -0,0 +1,242 @@
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/annotations"
. "github.com/smartystreets/goconvey/convey"
)
func TestAnnotationsApiEndpoint(t *testing.T) {
Convey("Given an annotation without a dashboard id", t, func() {
cmd := dtos.PostAnnotationsCmd{
Time: 1000,
Text: "annotation text",
Tags: []string{"tag1", "tag2"},
IsRegion: false,
}
updateCmd := dtos.UpdateAnnotationsCmd{
Time: 1000,
Text: "annotation text",
Tags: []string{"tag1", "tag2"},
IsRegion: false,
}
Convey("When user is an Org Viewer", func() {
role := m.ROLE_VIEWER
Convey("Should not be allowed to save an annotation", func() {
postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationById
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationRegion
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
})
})
Convey("When user is an Org Editor", func() {
role := m.ROLE_EDITOR
Convey("Should be able to save an annotation", func() {
postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationById
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationRegion
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
})
})
})
Convey("Given an annotation with a dashboard id and the dashboard does not have an acl", t, func() {
cmd := dtos.PostAnnotationsCmd{
Time: 1000,
Text: "annotation text",
Tags: []string{"tag1", "tag2"},
IsRegion: false,
DashboardId: 1,
PanelId: 1,
}
updateCmd := dtos.UpdateAnnotationsCmd{
Time: 1000,
Text: "annotation text",
Tags: []string{"tag1", "tag2"},
IsRegion: false,
Id: 1,
}
viewerRole := m.ROLE_VIEWER
editorRole := m.ROLE_EDITOR
aclMockResp := []*m.DashboardAclInfoDTO{
{Role: &viewerRole, Permission: m.PERMISSION_VIEW},
{Role: &editorRole, Permission: m.PERMISSION_EDIT},
}
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
query.Result = aclMockResp
return nil
})
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = []*m.Team{}
return nil
})
Convey("When user is an Org Viewer", func() {
role := m.ROLE_VIEWER
Convey("Should not be allowed to save an annotation", func() {
postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationById
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationRegion
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
})
})
Convey("When user is an Org Editor", func() {
role := m.ROLE_EDITOR
Convey("Should be able to save an annotation", func() {
postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationById
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationRegion
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
})
})
})
}
type fakeAnnotationsRepo struct {
}
func (repo *fakeAnnotationsRepo) Delete(params *annotations.DeleteParams) error {
return nil
}
func (repo *fakeAnnotationsRepo) Save(item *annotations.Item) error {
item.Id = 1
return nil
}
func (repo *fakeAnnotationsRepo) Update(item *annotations.Item) error {
return nil
}
func (repo *fakeAnnotationsRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) {
annotations := []*annotations.ItemDTO{{Id: 1}}
return annotations, nil
}
var fakeAnnoRepo *fakeAnnotationsRepo
func postAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PostAnnotationsCmd, 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.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
return PostAnnotation(c, cmd)
})
fakeAnnoRepo = &fakeAnnotationsRepo{}
annotations.SetRepository(fakeAnnoRepo)
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}
func putAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateAnnotationsCmd, 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.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
return UpdateAnnotation(c, cmd)
})
fakeAnnoRepo = &fakeAnnotationsRepo{}
annotations.SetRepository(fakeAnnoRepo)
sc.m.Put(routePattern, sc.defaultHandler)
fn(sc)
})
}

View File

@ -150,13 +150,13 @@ func (hs *HttpServer) registerRoutes() {
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
teamsRoute.Get("/:teamId", wrap(GetTeamById))
teamsRoute.Get("/search", wrap(SearchTeams))
teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
}, reqOrgAdmin)
teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById))
teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember))
})
// org information available to all users.
apiRoute.Group("/org", func(orgRoute RouteRegister) {
@ -252,19 +252,21 @@ func (hs *HttpServer) registerRoutes() {
dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUid))
dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard))
dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
dashboardRoute.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
dashboardRoute.Get("/home", wrap(GetHomeDashboard))
dashboardRoute.Get("/tags", GetDashboardTags)
dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser))
dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) {
dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
dashIdRoute.Post("/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
dashIdRoute.Group("/acl", func(aclRoute RouteRegister) {
aclRoute.Get("/", wrap(GetDashboardAclList))
@ -326,8 +328,8 @@ func (hs *HttpServer) registerRoutes() {
annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById))
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion))
annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation))
}, reqEditorRole)
annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation))
})
// error test
r.Get("/metrics/error", wrap(GenerateError))

105
pkg/api/common_test.go Normal file
View File

@ -0,0 +1,105 @@
package api
import (
"net/http"
"net/http/httptest"
"path/filepath"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/models"
macaron "gopkg.in/macaron.v1"
. "github.com/smartystreets/goconvey/convey"
)
func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn)
}
func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, 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.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
if sc.handlerFunc != nil {
return sc.handlerFunc(sc.context)
}
return nil
})
switch method {
case "GET":
sc.m.Get(routePattern, sc.defaultHandler)
case "DELETE":
sc.m.Delete(routePattern, sc.defaultHandler)
}
fn(sc)
})
}
func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
sc.resp = httptest.NewRecorder()
req, err := http.NewRequest(method, url, nil)
So(err, ShouldBeNil)
sc.req = req
return sc
}
func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
sc.resp = httptest.NewRecorder()
req, err := http.NewRequest(method, url, nil)
q := req.URL.Query()
for k, v := range queryParams {
q.Add(k, v)
}
req.URL.RawQuery = q.Encode()
So(err, ShouldBeNil)
sc.req = req
return sc
}
type scenarioContext struct {
m *macaron.Macaron
context *middleware.Context
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
}
func (sc *scenarioContext) exec() {
sc.m.ServeHTTP(sc.resp, sc.req)
}
type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *middleware.Context) Response
func setupScenarioContext(url string) *scenarioContext {
sc := &scenarioContext{
url: url,
}
viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New()
sc.m.Use(macaron.Renderer(macaron.RenderOptions{
Directory: viewsPath,
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.m.Use(middleware.GetContextHandler())
sc.m.Use(middleware.Sessioner(&session.Options{}))
return sc
}

View File

@ -38,9 +38,9 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error)
func dashboardGuardianResponse(err error) Response {
if err != nil {
return ApiError(500, "Error while checking dashboard permissions", err)
} else {
return ApiError(403, "Access denied to this dashboard", nil)
}
return ApiError(403, "Access denied to this dashboard", nil)
}
func GetDashboard(c *middleware.Context) Response {
@ -51,7 +51,6 @@ func GetDashboard(c *middleware.Context) Response {
guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
if canView, err := guardian.CanView(); err != nil || !canView {
fmt.Printf("%v", err)
return dashboardGuardianResponse(err)
}
@ -90,6 +89,7 @@ func GetDashboard(c *middleware.Context) Response {
IsFolder: dash.IsFolder,
FolderId: dash.FolderId,
FolderTitle: "Root",
Url: dash.GetUrl(),
}
// lookup folder title
@ -99,12 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
return ApiError(500, "Dashboard folder could not be read", err)
}
meta.FolderTitle = query.Result.Title
}
if dash.IsFolder {
meta.Url = m.GetFolderUrl(dash.Uid, dash.Slug)
} else {
meta.Url = m.GetDashboardUrl(dash.Uid, dash.Slug)
meta.FolderSlug = query.Result.Slug
}
// make sure db version is in sync with json model version
@ -201,7 +196,14 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
dash := cmd.GetDashboardModel()
guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
dashId := dash.Id
// if new dashboard, use parent folder permissions instead
if dashId == 0 {
dashId = cmd.FolderId
}
guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
if canSave, err := guardian.CanSave(); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
@ -244,7 +246,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
}
if err != nil {
if err == m.ErrDashboardWithSameNameExists {
if err == m.ErrDashboardWithSameUIDExists {
return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
}
if err == m.ErrDashboardVersionMismatch {
@ -268,12 +270,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
}
var url string
if dash.IsFolder {
url = m.GetFolderUrl(dashboard.Uid, dashboard.Slug)
} else {
url = m.GetDashboardUrl(dashboard.Uid, dashboard.Slug)
}
dashboard.IsFolder = dash.IsFolder
c.TimeRequest(metrics.M_Api_Dashboard_Save)
return Json(200, util.DynMap{
@ -282,7 +279,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
"version": dashboard.Version,
"id": dashboard.Id,
"uid": dashboard.Uid,
"url": url,
"url": dashboard.GetUrl(),
})
}
@ -489,3 +486,19 @@ func GetDashboardTags(c *middleware.Context) {
c.JSON(200, query.Result)
}
func GetFoldersForSignedInUser(c *middleware.Context) Response {
title := c.Query("query")
query := m.GetFoldersForSignedInUserQuery{
OrgId: c.OrgId,
SignedInUser: c.SignedInUser,
Title: title,
}
err := bus.Dispatch(&query)
if err != nil {
return ApiError(500, "Failed to get folders from database", err)
}
return Json(200, query.Result)
}

View File

@ -51,6 +51,14 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
})
}
if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
if err != nil {
return ApiError(500, "Error while checking dashboard 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.ErrDashboardPermissionDashboardEmpty {
return ApiError(409, err.Error(), err)
@ -70,6 +78,14 @@ func DeleteDashboardAcl(c *middleware.Context) Response {
return dashboardGuardianResponse(err)
}
if okToDelete, err := guardian.CheckPermissionBeforeRemove(m.PERMISSION_ADMIN, aclId); err != nil || !okToDelete {
if err != nil {
return ApiError(500, "Error while checking dashboard permissions", err)
}
return ApiError(403, "Cannot remove own admin permission for a folder", nil)
}
cmd := m.RemoveDashboardAclCommand{OrgId: c.OrgId, AclId: aclId}
if err := bus.Dispatch(&cmd); err != nil {
return ApiError(500, "Failed to delete permission for user", err)

View File

@ -3,8 +3,10 @@ package api
import (
"testing"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
@ -37,6 +39,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
return nil
})
// This tests four scenarios:
// 1. user is an org admin
// 2. user is an org editor AND has been granted admin permission for the dashboard
// 3. user is an org viewer AND has been granted edit permission for the dashboard
// 4. user is an org editor AND has no permissions for the dashboard
Convey("When user is org admin", func() {
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
Convey("Should be able to access ACL", func() {
@ -54,9 +62,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
})
})
Convey("When user is 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) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
Convey("Should be able to access ACL", func() {
sc.handlerFunc = GetDashboardAclList
@ -67,7 +75,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
return nil
@ -81,6 +89,52 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
})
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
return nil
})
Convey("Should not be able to delete their own Admin permission", func() {
sc.handlerFunc = DeleteDashboardAcl
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 403)
})
})
Convey("Should not be able to downgrade their own Admin permission", func() {
cmd := dtos.UpdateDashboardAclCommand{
Items: []dtos.DashboardAclUpdateItem{
{UserId: TestUserID, Permission: m.PERMISSION_EDIT},
},
}
postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
CallPostAcl(sc)
So(sc.resp.Code, ShouldEqual, 403)
})
})
Convey("Should be able to update permissions", func() {
cmd := dtos.UpdateDashboardAclCommand{
Items: []dtos.DashboardAclUpdateItem{
{UserId: TestUserID, Permission: m.PERMISSION_ADMIN},
{UserId: 2, Permission: m.PERMISSION_EDIT},
},
}
postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
CallPostAcl(sc)
So(sc.resp.Code, ShouldEqual, 200)
})
})
Convey("When user is a member of a team in the ACL with admin permission", func() {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"})
@ -99,11 +153,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
})
})
Convey("When user is editor 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_EDITOR, func(sc *scenarioContext) {
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) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
Convey("Should not be able to access ACL", func() {
// Getting the permissions is an Admin permission
Convey("Should not be able to get list of permissions from ACL", func() {
sc.handlerFunc = GetDashboardAclList
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
@ -111,7 +166,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
})
})
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) {
mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
@ -127,7 +182,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
})
})
Convey("When user is 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) {
Convey("Should not be able to access ACL", func() {
@ -172,3 +227,32 @@ func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardA
return dtos
}
func CallPostAcl(sc *scenarioContext) {
bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
return nil
})
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
func postAclScenario(desc string, url string, routePattern string, role m.RoleType, 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.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
return UpdateDashboardAcl(c, cmd)
})
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}

View File

@ -2,12 +2,8 @@ package api
import (
"encoding/json"
"path/filepath"
"testing"
macaron "gopkg.in/macaron.v1"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
@ -32,6 +28,10 @@ func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem)
var fakeRepo *fakeDashboardRepo
// This tests two main scenarios. If a user has access to execute an action on a dashboard:
// 1. and the dashboard is in a folder which does not have an acl
// 2. and the dashboard is in a folder which does have an acl
func TestDashboardApiEndpoint(t *testing.T) {
Convey("Given a dashboard with a parent folder which does not have an acl", t, func() {
fakeDash := m.NewDashboard("Child dash")
@ -79,6 +79,10 @@ func TestDashboardApiEndpoint(t *testing.T) {
}),
}
// This tests two scenarios:
// 1. user is an org viewer
// 2. user is an org editor
Convey("When user is an Org Viewer", func() {
role := m.ROLE_VIEWER
@ -279,6 +283,14 @@ func TestDashboardApiEndpoint(t *testing.T) {
}),
}
// This tests six scenarios:
// 1. user is an org viewer AND has no permissions for this dashboard
// 2. user is an org editor AND has no permissions for this dashboard
// 3. user is an org viewer AND has been granted edit permission for the dashboard
// 4. user is an org viewer AND all viewers have edit permission for this dashboard
// 5. user is an org viewer AND has been granted an admin permission
// 6. user is an org editor AND has been granted a view permission
Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
role := m.ROLE_VIEWER
@ -800,20 +812,7 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
sc := &scenarioContext{
url: url,
}
viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New()
sc.m.Use(macaron.Renderer(macaron.RenderOptions{
Directory: viewsPath,
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.m.Use(middleware.GetContextHandler())
sc.m.Use(middleware.Sessioner(&session.Options{}))
sc := setupScenarioContext(url)
sc.defaultHandler = wrap(func(c *middleware.Context) Response {
sc.context = c
sc.context.UserId = TestUserID

View File

@ -2,17 +2,11 @@ package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/grafana/grafana/pkg/models"
macaron "gopkg.in/macaron.v1"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
. "github.com/smartystreets/goconvey/convey"
)
@ -54,88 +48,3 @@ func TestDataSourcesProxy(t *testing.T) {
})
})
}
func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn)
}
func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
sc := &scenarioContext{
url: url,
}
viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New()
sc.m.Use(macaron.Renderer(macaron.RenderOptions{
Directory: viewsPath,
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.m.Use(middleware.GetContextHandler())
sc.m.Use(middleware.Sessioner(&session.Options{}))
sc.defaultHandler = wrap(func(c *middleware.Context) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
if sc.handlerFunc != nil {
return sc.handlerFunc(sc.context)
}
return nil
})
switch method {
case "GET":
sc.m.Get(routePattern, sc.defaultHandler)
case "DELETE":
sc.m.Delete(routePattern, sc.defaultHandler)
}
fn(sc)
})
}
func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
sc.resp = httptest.NewRecorder()
req, err := http.NewRequest(method, url, nil)
So(err, ShouldBeNil)
sc.req = req
return sc
}
func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
sc.resp = httptest.NewRecorder()
req, err := http.NewRequest(method, url, nil)
q := req.URL.Query()
for k, v := range queryParams {
q.Add(k, v)
}
req.URL.RawQuery = q.Encode()
So(err, ShouldBeNil)
sc.req = req
return sc
}
type scenarioContext struct {
m *macaron.Macaron
context *middleware.Context
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
}
func (sc *scenarioContext) exec() {
sc.m.ServeHTTP(sc.resp, sc.req)
}
type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *middleware.Context) Response

View File

@ -20,6 +20,7 @@ type AlertRule struct {
EvalData *simplejson.Json `json:"evalData"`
ExecutionError string `json:"executionError"`
DashbboardUri string `json:"dashboardUri"`
CanEdit bool `json:"canEdit"`
}
type AlertNotification struct {

View File

@ -27,6 +27,7 @@ type DashboardMeta struct {
IsFolder bool `json:"isFolder"`
FolderId int64 `json:"folderId"`
FolderTitle string `json:"folderTitle"`
FolderSlug string `json:"folderSlug"`
}
type DashboardFullWithMeta struct {

View File

@ -102,8 +102,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
}
dashboardChildNavs := []*dtos.NavLink{
{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true},
{Divider: true, HideFromTabs: true},
{Text: "Home", Id: "home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true},
{Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true},
{Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "gicon gicon-manage"},
{Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "gicon gicon-playlists"},
{Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "gicon gicon-snapshots"},
@ -261,7 +261,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
if c.IsGrafanaAdmin {
cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
Divider: true, HideFromTabs: true,
Divider: true, HideFromTabs: true, Id: "admin-divider", Text: "Text",
})
cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
Text: "Server Admin",

View File

@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
. "github.com/smartystreets/goconvey/convey"
)
@ -19,6 +20,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
fakeDash.Id = 1
fakeDash.FolderId = 1
fakeDash.HasAcl = false
fakeDash.Uid = util.GenerateShortUid()
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
query.Result = fakeDash

View File

@ -159,10 +159,6 @@ type SetAlertStateCommand struct {
Timestamp time.Time
}
type DeleteAlertCommand struct {
AlertId int64
}
//Queries
type GetAlertsQuery struct {
OrgId int64

View File

@ -9,20 +9,21 @@ import (
"github.com/gosimple/slug"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
// Typed errors
var (
ErrDashboardNotFound = errors.New("Dashboard not found")
ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found")
ErrDashboardWithSameNameExists = errors.New("A dashboard with the same name already exists")
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
ErrDashboardNotFound = errors.New("Dashboard not found")
ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found")
ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists")
ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists")
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
)
type UpdatePluginDashboardError struct {
@ -66,7 +67,6 @@ type Dashboard struct {
// NewDashboard creates a new dashboard
func NewDashboard(title string) *Dashboard {
dash := &Dashboard{}
dash.Uid = util.GenerateShortUid()
dash.Data = simplejson.New()
dash.Data.Set("title", title)
dash.Title = title
@ -115,8 +115,6 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
if uid, err := dash.Data.Get("uid").String(); err == nil {
dash.Uid = uid
} else {
dash.Uid = util.GenerateShortUid()
}
return dash
@ -159,6 +157,20 @@ func SlugifyTitle(title string) string {
return slug.Make(strings.ToLower(title))
}
// GetUrl return the html url for a folder if it's folder, otherwise for a dashboard
func (dash *Dashboard) GetUrl() string {
return GetDashboardFolderUrl(dash.IsFolder, dash.Uid, dash.Slug)
}
// GetDashboardFolderUrl return the html url for a folder if it's folder, otherwise for a dashboard
func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string {
if isFolder {
return GetFolderUrl(uid, slug)
}
return GetDashboardUrl(uid, slug)
}
// GetDashboardUrl return the html url for a dashboard
func GetDashboardUrl(uid string, slug string) string {
return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug)
@ -222,6 +234,14 @@ type GetDashboardsQuery struct {
Result []*Dashboard
}
type GetDashboardPermissionsForUserQuery struct {
DashboardIds []int64
OrgId int64
UserId int64
OrgRole RoleType
Result []*DashboardPermissionForUser
}
type GetDashboardsByPluginIdQuery struct {
OrgId int64
PluginId string
@ -239,3 +259,21 @@ type GetDashboardsBySlugQuery struct {
Result []*Dashboard
}
type GetFoldersForSignedInUserQuery struct {
OrgId int64
SignedInUser *SignedInUser
Title string
Result []*DashboardFolder
}
type DashboardFolder struct {
Id int64 `json:"id"`
Title string `json:"title"`
}
type DashboardPermissionForUser struct {
DashboardId int64 `json:"dashboardId"`
Permission PermissionType `json:"permission"`
PermissionName string `json:"permissionName"`
}

View File

@ -71,6 +71,7 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
qr := &tsdb.QueryResult{
RefId: r.RefId,
Series: []*tsdb.TimeSeries{},
Tables: []*tsdb.Table{},
}
if r.Error != "" {
@ -124,6 +125,7 @@ func (tw *DatasourcePluginWrapper) mapTable(t *datasource.Table) (*tsdb.Table, e
})
}
table.Rows = make([]tsdb.RowValues, 0)
for _, r := range t.GetRows() {
row := tsdb.RowValues{}
for _, rv := range r.Values {

View File

@ -1,121 +1,121 @@
package alerting
import (
"testing"
"time"
"github.com/benbjohnson/clock"
)
func inspectTick(tick time.Time, last time.Time, offset time.Duration, t *testing.T) {
if !tick.Equal(last.Add(time.Duration(1) * time.Second)) {
t.Fatalf("expected a tick 1 second more than prev, %s. got: %s", last, tick)
}
}
// returns the new last tick seen
func assertAdvanceUntil(ticker *Ticker, last, desiredLast time.Time, offset, wait time.Duration, t *testing.T) time.Time {
for {
select {
case tick := <-ticker.C:
inspectTick(tick, last, offset, t)
last = tick
case <-time.NewTimer(wait).C:
if last.Before(desiredLast) {
t.Fatalf("waited %s for ticker to advance to %s, but only went up to %s", wait, desiredLast, last)
}
if last.After(desiredLast) {
t.Fatalf("timer advanced too far. should only have gone up to %s, but it went up to %s", desiredLast, last)
}
return last
}
}
}
func assertNoAdvance(ticker *Ticker, desiredLast time.Time, wait time.Duration, t *testing.T) {
for {
select {
case tick := <-ticker.C:
t.Fatalf("timer should have stayed at %s, instead it advanced to %s", desiredLast, tick)
case <-time.NewTimer(wait).C:
return
}
}
}
func TestTickerRetro1Hour(t *testing.T) {
offset := time.Duration(10) * time.Second
last := time.Unix(0, 0)
mock := clock.NewMock()
mock.Add(time.Duration(1) * time.Hour)
desiredLast := mock.Now().Add(-offset)
ticker := NewTicker(last, offset, mock)
last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
}
func TestAdvanceWithUpdateOffset(t *testing.T) {
offset := time.Duration(10) * time.Second
last := time.Unix(0, 0)
mock := clock.NewMock()
mock.Add(time.Duration(1) * time.Hour)
desiredLast := mock.Now().Add(-offset)
ticker := NewTicker(last, offset, mock)
last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
// lowering offset should see a few more ticks
offset = time.Duration(5) * time.Second
ticker.updateOffset(offset)
desiredLast = mock.Now().Add(-offset)
last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(9)*time.Millisecond, t)
assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
// advancing clock should see even more ticks
mock.Add(time.Duration(1) * time.Hour)
desiredLast = mock.Now().Add(-offset)
last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(8)*time.Millisecond, t)
assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
}
func getCase(lastSeconds, offsetSeconds int) (time.Time, time.Duration) {
last := time.Unix(int64(lastSeconds), 0)
offset := time.Duration(offsetSeconds) * time.Second
return last, offset
}
func TestTickerNoAdvance(t *testing.T) {
// it's 00:01:00 now. what are some cases where we don't want the ticker to advance?
mock := clock.NewMock()
mock.Add(time.Duration(60) * time.Second)
type Case struct {
last int
offset int
}
// note that some cases add up to now, others go into the future
cases := []Case{
{50, 10},
{50, 30},
{59, 1},
{59, 10},
{59, 30},
{60, 1},
{60, 10},
{60, 30},
{90, 1},
{90, 10},
{90, 30},
}
for _, c := range cases {
last, offset := getCase(c.last, c.offset)
ticker := NewTicker(last, offset, mock)
assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
}
}
//import (
// "testing"
// "time"
//
// "github.com/benbjohnson/clock"
//)
//
//func inspectTick(tick time.Time, last time.Time, offset time.Duration, t *testing.T) {
// if !tick.Equal(last.Add(time.Duration(1) * time.Second)) {
// t.Fatalf("expected a tick 1 second more than prev, %s. got: %s", last, tick)
// }
//}
//
//// returns the new last tick seen
//func assertAdvanceUntil(ticker *Ticker, last, desiredLast time.Time, offset, wait time.Duration, t *testing.T) time.Time {
// for {
// select {
// case tick := <-ticker.C:
// inspectTick(tick, last, offset, t)
// last = tick
// case <-time.NewTimer(wait).C:
// if last.Before(desiredLast) {
// t.Fatalf("waited %s for ticker to advance to %s, but only went up to %s", wait, desiredLast, last)
// }
// if last.After(desiredLast) {
// t.Fatalf("timer advanced too far. should only have gone up to %s, but it went up to %s", desiredLast, last)
// }
// return last
// }
// }
//}
//
//func assertNoAdvance(ticker *Ticker, desiredLast time.Time, wait time.Duration, t *testing.T) {
// for {
// select {
// case tick := <-ticker.C:
// t.Fatalf("timer should have stayed at %s, instead it advanced to %s", desiredLast, tick)
// case <-time.NewTimer(wait).C:
// return
// }
// }
//}
//
//func TestTickerRetro1Hour(t *testing.T) {
// offset := time.Duration(10) * time.Second
// last := time.Unix(0, 0)
// mock := clock.NewMock()
// mock.Add(time.Duration(1) * time.Hour)
// desiredLast := mock.Now().Add(-offset)
// ticker := NewTicker(last, offset, mock)
//
// last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
// assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
//
//}
//
//func TestAdvanceWithUpdateOffset(t *testing.T) {
// offset := time.Duration(10) * time.Second
// last := time.Unix(0, 0)
// mock := clock.NewMock()
// mock.Add(time.Duration(1) * time.Hour)
// desiredLast := mock.Now().Add(-offset)
// ticker := NewTicker(last, offset, mock)
//
// last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
// assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
//
// // lowering offset should see a few more ticks
// offset = time.Duration(5) * time.Second
// ticker.updateOffset(offset)
// desiredLast = mock.Now().Add(-offset)
// last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(9)*time.Millisecond, t)
// assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
//
// // advancing clock should see even more ticks
// mock.Add(time.Duration(1) * time.Hour)
// desiredLast = mock.Now().Add(-offset)
// last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(8)*time.Millisecond, t)
// assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
//
//}
//
//func getCase(lastSeconds, offsetSeconds int) (time.Time, time.Duration) {
// last := time.Unix(int64(lastSeconds), 0)
// offset := time.Duration(offsetSeconds) * time.Second
// return last, offset
//}
//
//func TestTickerNoAdvance(t *testing.T) {
//
// // it's 00:01:00 now. what are some cases where we don't want the ticker to advance?
// mock := clock.NewMock()
// mock.Add(time.Duration(60) * time.Second)
//
// type Case struct {
// last int
// offset int
// }
//
// // note that some cases add up to now, others go into the future
// cases := []Case{
// {50, 10},
// {50, 30},
// {59, 1},
// {59, 10},
// {59, 30},
// {60, 1},
// {60, 10},
// {60, 30},
// {90, 1},
// {90, 10},
// {90, 30},
// }
// for _, c := range cases {
// last, offset := getCase(c.last, c.offset)
// ticker := NewTicker(last, offset, mock)
// assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
// }
//}

View File

@ -10,14 +10,16 @@ type Repository interface {
}
type ItemQuery struct {
OrgId int64 `json:"orgId"`
From int64 `json:"from"`
To int64 `json:"to"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
Tags []string `json:"tags"`
Type string `json:"type"`
OrgId int64 `json:"orgId"`
From int64 `json:"from"`
To int64 `json:"to"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
AnnotationId int64 `json:"annotationId"`
RegionId int64 `json:"regionId"`
Tags []string `json:"tags"`
Type string `json:"type"`
Limit int64 `json:"limit"`
}

View File

@ -55,6 +55,10 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
return false, err
}
return g.checkAcl(permission, acl)
}
func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) {
orgRole := g.user.OrgRole
teamAclItems := []*m.DashboardAclInfoDTO{}
@ -79,18 +83,18 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
}
}
// do we have group rules?
// do we have team rules?
if len(teamAclItems) == 0 {
return false, nil
}
// load groups
// load teams
teams, err := g.getTeams()
if err != nil {
return false, err
}
// evalute group rules
// evalute team rules
for _, p := range acl {
for _, ug := range teams {
if ug.Id == p.TeamId && p.Permission >= permission {
@ -102,7 +106,41 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
return false, nil
}
// Returns dashboard acl
func (g *DashboardGuardian) CheckPermissionBeforeRemove(permission m.PermissionType, aclIdToRemove int64) (bool, error) {
if g.user.OrgRole == m.ROLE_ADMIN {
return true, nil
}
acl, err := g.GetAcl()
if err != nil {
return false, err
}
for i, p := range acl {
if p.Id == aclIdToRemove {
acl = append(acl[:i], acl[i+1:]...)
break
}
}
return g.checkAcl(permission, acl)
}
func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
if g.user.OrgRole == m.ROLE_ADMIN {
return true, nil
}
acl := []*m.DashboardAclInfoDTO{}
for _, p := range updatePermissions {
acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission})
}
return g.checkAcl(permission, acl)
}
// GetAcl returns dashboard acl
func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
if g.acl != nil {
return g.acl, nil

View File

@ -14,7 +14,6 @@ func init() {
bus.AddHandler("sql", SaveAlerts)
bus.AddHandler("sql", HandleAlertsQuery)
bus.AddHandler("sql", GetAlertById)
bus.AddHandler("sql", DeleteAlertById)
bus.AddHandler("sql", GetAllAlertQueryHandler)
bus.AddHandler("sql", SetAlertState)
bus.AddHandler("sql", GetAlertStatesForDashboard)
@ -61,12 +60,6 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro
return nil
}
func DeleteAlertById(cmd *m.DeleteAlertCommand) error {
return inTransaction(func(sess *DBSession) error {
return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess)
})
}
func HandleAlertsQuery(query *m.GetAlertsQuery) error {
var sql bytes.Buffer
params := make([]interface{}, 0)

View File

@ -138,6 +138,17 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
sql.WriteString(`WHERE annotation.org_id = ?`)
params = append(params, query.OrgId)
if query.AnnotationId != 0 {
fmt.Print("annotation query")
sql.WriteString(` AND annotation.id = ?`)
params = append(params, query.AnnotationId)
}
if query.RegionId != 0 {
sql.WriteString(` AND annotation.region_id = ?`)
params = append(params, query.RegionId)
}
if query.AlertId != 0 {
sql.WriteString(` AND annotation.alert_id = ?`)
params = append(params, query.AlertId)
@ -197,6 +208,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit))
items := make([]*annotations.ItemDTO, 0)
if err := x.Sql(sql.String(), params...).Find(&items); err != nil {
return nil, err
}

View File

@ -51,6 +51,20 @@ func TestAnnotations(t *testing.T) {
So(err, ShouldBeNil)
So(annotation.Id, ShouldBeGreaterThan, 0)
annotation2 := &annotations.Item{
OrgId: 1,
UserId: 1,
DashboardId: 2,
Text: "hello",
Type: "alert",
Epoch: 20,
Tags: []string{"outage", "error", "type:outage", "server:server-1"},
RegionId: 1,
}
err = repo.Save(annotation2)
So(err, ShouldBeNil)
So(annotation2.Id, ShouldBeGreaterThan, 0)
Convey("Can query for annotation", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
@ -67,6 +81,28 @@ func TestAnnotations(t *testing.T) {
})
})
Convey("Can query for annotation by id", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
AnnotationId: annotation2.Id,
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 1)
So(items[0].Id, ShouldEqual, annotation2.Id)
})
Convey("Can query for annotation by region id", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
RegionId: annotation2.RegionId,
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 1)
So(items[0].Id, ShouldEqual, annotation2.Id)
})
Convey("Should not find any when item is outside time range", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,

View File

@ -1,12 +1,14 @@
package sqlstore
import (
"strings"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/util"
)
func init() {
@ -18,16 +20,20 @@ func init() {
bus.AddHandler("sql", GetDashboardTags)
bus.AddHandler("sql", GetDashboardSlugById)
bus.AddHandler("sql", GetDashboardsByPluginId)
bus.AddHandler("sql", GetFoldersForSignedInUser)
bus.AddHandler("sql", GetDashboardPermissionsForUser)
}
var generateNewUid func() string = util.GenerateShortUid
func SaveDashboard(cmd *m.SaveDashboardCommand) error {
return inTransaction(func(sess *DBSession) error {
dash := cmd.GetDashboardModel()
// try get existing dashboard
var existing, sameTitle m.Dashboard
var existing m.Dashboard
if dash.Id > 0 {
if dash.Id != 0 {
dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
if err != nil {
return err
@ -49,25 +55,40 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
if existing.PluginId != "" && cmd.Overwrite == false {
return m.UpdatePluginDashboardError{PluginId: existing.PluginId}
}
}
} else if dash.Uid != "" {
var sameUid m.Dashboard
sameUidExists, err := sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&sameUid)
if err != nil {
return err
}
sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle)
if err != nil {
return err
}
if sameTitleExists {
// another dashboard with same name
if dash.Id != sameTitle.Id {
if cmd.Overwrite {
dash.Id = sameTitle.Id
dash.Version = sameTitle.Version
} else {
return m.ErrDashboardWithSameNameExists
if sameUidExists {
// another dashboard with same uid
if dash.Id != sameUid.Id {
if cmd.Overwrite {
dash.Id = sameUid.Id
dash.Version = sameUid.Version
} else {
return m.ErrDashboardWithSameUIDExists
}
}
}
}
if dash.Uid == "" {
uid, err := generateNewDashboardUid(sess, dash.OrgId)
if err != nil {
return err
}
dash.Uid = uid
dash.Data.Set("uid", uid)
}
err := guaranteeDashboardNameIsUniqueInFolder(sess, dash)
if err != nil {
return err
}
err = setHasAcl(sess, dash)
if err != nil {
return err
@ -89,7 +110,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
dash.Updated = cmd.UpdatedAt
}
affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash)
}
if err != nil {
@ -138,6 +159,39 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
return err
})
}
func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
for i := 0; i < 3; i++ {
uid := generateNewUid()
exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.Dashboard{})
if err != nil {
return "", err
}
if !exists {
return uid, nil
}
}
return "", m.ErrDashboardFailedGenerateUniqueUid
}
func guaranteeDashboardNameIsUniqueInFolder(sess *DBSession, dash *m.Dashboard) error {
var sameNameInFolder m.Dashboard
sameNameInFolderExist, err := sess.Where("org_id=? AND title=? AND folder_id = ? AND uid <> ?",
dash.OrgId, dash.Title, dash.FolderId, dash.Uid).
Get(&sameNameInFolder)
if err != nil {
return err
}
if sameNameInFolderExist {
return m.ErrDashboardWithSameNameInFolderExists
}
return nil
}
func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
// check if parent has acl
@ -258,18 +312,12 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
for _, item := range res {
hit, exists := hits[item.Id]
if !exists {
var url string
if item.IsFolder {
url = m.GetFolderUrl(item.Uid, item.Slug)
} else {
url = m.GetDashboardUrl(item.Uid, item.Slug)
}
hit = &search.Hit{
Id: item.Id,
Uid: item.Uid,
Title: item.Title,
Uri: "db/" + item.Slug,
Url: url,
Url: m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
Type: getHitType(item),
FolderId: item.FolderId,
FolderTitle: item.FolderTitle,
@ -301,6 +349,52 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
return err
}
func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
query.Result = make([]*m.DashboardFolder, 0)
var err error
if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
sql := `SELECT distinct d.id, d.title
FROM dashboard AS d WHERE d.is_folder = ?
ORDER BY d.title ASC`
err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result)
} else {
params := make([]interface{}, 0)
sql := `SELECT distinct d.id, d.title
FROM dashboard AS d
LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id
LEFT JOIN team_member AS ugm ON ugm.team_id = da.team_id
LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ? AND ouRole.org_id = ?`
params = append(params, query.SignedInUser.UserId)
params = append(params, query.SignedInUser.UserId)
params = append(params, query.OrgId)
sql += `WHERE
d.org_id = ? AND
d.is_folder = 1 AND
(
(d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
OR (d.has_acl = 0 AND ouRole.id IS NOT NULL)
)`
params = append(params, query.OrgId)
params = append(params, query.SignedInUser.UserId)
params = append(params, query.SignedInUser.UserId)
if len(query.Title) > 0 {
sql += " AND d.title " + dialect.LikeStr() + " ?"
params = append(params, "%"+query.Title+"%")
}
sql += ` ORDER BY d.title ASC`
err = x.Sql(sql, params...).Find(&query.Result)
}
return err
}
func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
return inTransaction(func(sess *DBSession) error {
dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId}
@ -353,6 +447,76 @@ func GetDashboards(query *m.GetDashboardsQuery) error {
return nil
}
// GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s)
// The function takes in a list of dashboard ids and the user id and role
func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery) error {
if len(query.DashboardIds) == 0 {
return m.ErrCommandValidationFailed
}
if query.OrgRole == m.ROLE_ADMIN {
var permissions = make([]*m.DashboardPermissionForUser, 0)
for _, d := range query.DashboardIds {
permissions = append(permissions, &m.DashboardPermissionForUser{
DashboardId: d,
Permission: m.PERMISSION_ADMIN,
PermissionName: m.PERMISSION_ADMIN.String(),
})
}
query.Result = permissions
return nil
}
params := make([]interface{}, 0)
// check dashboards that have ACLs via user id, team id or role
sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission
FROM dashboard AS d
LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id
LEFT JOIN team_member as ugm on ugm.team_id = da.team_id
LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
`
params = append(params, query.UserId)
//check the user's role for dashboards that do not have hasAcl set
sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?`
params = append(params, query.UserId)
params = append(params, query.OrgId)
sql += `
LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS 'role'
UNION SELECT 2 AS permission, 'Editor' AS 'role'
UNION SELECT 4 AS permission, 'Admin' AS 'role') pt ON ouRole.role = pt.role
WHERE
d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
for _, id := range query.DashboardIds {
params = append(params, id)
}
sql += ` AND
d.org_id = ? AND
(
(d.has_acl = ? AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
)
group by d.id
order by d.id asc`
params = append(params, dialect.BooleanStr(true))
params = append(params, query.OrgId)
params = append(params, query.UserId)
params = append(params, query.UserId)
params = append(params, dialect.BooleanStr(false))
err := x.Sql(sql, params...).Find(&query.Result)
for _, p := range query.Result {
p.PermissionName = p.Permission.String()
}
return err
}
func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error {
var dashboards = make([]*m.Dashboard, 0)
whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)

View File

@ -126,14 +126,10 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
}
func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
dashboardFilter := fmt.Sprintf(`IN (
SELECT %d
UNION
SELECT folder_id from dashboard where id = %d
)`, query.DashboardId, query.DashboardId)
var err error
rawSQL := `
SELECT
if query.DashboardId == 0 {
sql := `SELECT
da.id,
da.org_id,
da.dashboard_id,
@ -143,44 +139,71 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.role,
da.created,
da.updated,
u.login AS user_login,
u.email AS user_email,
ug.name AS team
FROM` + dialect.Quote("dashboard_acl") + ` as da
LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
LEFT OUTER JOIN team ug on ug.id = da.team_id
WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
'' as user_login,
'' as user_email,
'' as team
FROM dashboard_acl as da
WHERE da.dashboard_id = -1`
query.Result = make([]*m.DashboardAclInfoDTO, 0)
err = x.SQL(sql).Find(&query.Result)
-- Also include default permission if has_acl = 0
} else {
dashboardFilter := fmt.Sprintf(`IN (
SELECT %d
UNION
SELECT folder_id from dashboard where id = %d
)`, query.DashboardId, query.DashboardId)
UNION
SELECT
da.id,
da.org_id,
da.dashboard_id,
da.user_id,
da.team_id,
da.permission,
da.role,
da.created,
da.updated,
'' as user_login,
'' as user_email,
'' as team
FROM dashboard_acl as da,
dashboard as dash
LEFT JOIN dashboard folder on dash.folder_id = folder.id
WHERE
dash.id = ? AND (
dash.has_acl = ` + dialect.BooleanStr(false) + ` or
folder.has_acl = ` + dialect.BooleanStr(false) + `
) AND
da.dashboard_id = -1
ORDER BY 1 ASC
`
rawSQL := `
SELECT
da.id,
da.org_id,
da.dashboard_id,
da.user_id,
da.team_id,
da.permission,
da.role,
da.created,
da.updated,
u.login AS user_login,
u.email AS user_email,
ug.name AS team
FROM` + dialect.Quote("dashboard_acl") + ` as da
LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
LEFT OUTER JOIN team ug on ug.id = da.team_id
WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
query.Result = make([]*m.DashboardAclInfoDTO, 0)
err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
-- Also include default permission if has_acl = 0
UNION
SELECT
da.id,
da.org_id,
da.dashboard_id,
da.user_id,
da.team_id,
da.permission,
da.role,
da.created,
da.updated,
'' as user_login,
'' as user_email,
'' as team
FROM dashboard_acl as da,
dashboard as dash
LEFT JOIN dashboard folder on dash.folder_id = folder.id
WHERE
dash.id = ? AND (
dash.has_acl = ` + dialect.BooleanStr(false) + ` or
folder.has_acl = ` + dialect.BooleanStr(false) + `
) AND
da.dashboard_id = -1
ORDER BY 1 ASC
`
query.Result = make([]*m.DashboardAclInfoDTO, 0)
err = x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
}
for _, p := range query.Result {
p.PermissionName = p.Permission.String()

View File

@ -232,5 +232,23 @@ func TestDashboardAclDataAccess(t *testing.T) {
})
})
Convey("Given a root folder", func() {
var rootFolderId int64 = 0
Convey("When reading dashboard acl should return default permissions", func() {
query := m.GetDashboardAclInfoListQuery{DashboardId: rootFolderId, OrgId: 1}
err := GetDashboardAclInfoList(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
defaultPermissionsId := -1
So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId)
So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER)
So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId)
So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR)
})
})
})
}

View File

@ -0,0 +1,349 @@
package sqlstore
import (
"testing"
"github.com/go-xorm/xorm"
. "github.com/smartystreets/goconvey/convey"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
)
func TestDashboardFolderDataAccess(t *testing.T) {
var x *xorm.Engine
Convey("Testing DB", t, func() {
x = InitTestDB(t)
Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
currentUser := createUser("viewer", "Viewer", false)
Convey("and no acls are set", func() {
Convey("should return all dashboards", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and acl is set for dashboard folder", func() {
var otherUser int64 = 999
updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
Convey("should not return folder", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
})
Convey("when the user is given permission", func() {
updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
Convey("should be able to access folder", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("when the user is an admin", func() {
Convey("should be able to access folder", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{
UserId: currentUser.Id,
OrgId: 1,
OrgRole: m.ROLE_ADMIN,
},
OrgId: 1,
DashboardIds: []int64{folder.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
})
Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
var otherUser int64 = 999
aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
removeAcl(aclId)
updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
Convey("should not return folder or child", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
})
Convey("when the user is given permission to child", func() {
updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
Convey("should be able to search for child dashboard but not folder", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, childDash.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("when the user is an admin", func() {
Convey("should be able to search for child dash and folder", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{
UserId: currentUser.Id,
OrgId: 1,
OrgRole: m.ROLE_ADMIN,
},
OrgId: 1,
DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 3)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, childDash.Id)
So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
})
})
})
})
Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
currentUser := createUser("viewer", "Viewer", false)
var rootFolderId int64 = 0
Convey("and one folder is expanded, the other collapsed", func() {
Convey("should return dashboards in root and expanded folder", func() {
query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 4)
So(query.Result[0].Id, ShouldEqual, folder1.Id)
So(query.Result[1].Id, ShouldEqual, folder2.Id)
So(query.Result[2].Id, ShouldEqual, childDash1.Id)
So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and acl is set for one dashboard folder", func() {
var otherUser int64 = 999
updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
So(movedDash.HasAcl, ShouldBeTrue)
Convey("should not return folder with acl or its children", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
OrgId: 1,
DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
So(movedDash.HasAcl, ShouldBeFalse)
Convey("should return folder without acl and its children", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
OrgId: 1,
DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 4)
So(query.Result[0].Id, ShouldEqual, folder2.Id)
So(query.Result[1].Id, ShouldEqual, childDash1.Id)
So(query.Result[2].Id, ShouldEqual, childDash2.Id)
So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
So(movedDash.HasAcl, ShouldBeTrue)
Convey("should return folder without acl but not the dashboard with acl", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
OrgId: 1,
DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 3)
So(query.Result[0].Id, ShouldEqual, folder2.Id)
So(query.Result[1].Id, ShouldEqual, childDash2.Id)
So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
})
})
})
})
Convey("Given two dashboard folders", func() {
folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
adminUser := createUser("admin", "Admin", true)
editorUser := createUser("editor", "Editor", false)
viewerUser := createUser("viewer", "Viewer", false)
Convey("Admin users", func() {
Convey("Should have write access to all dashboard folders", func() {
query := m.GetFoldersForSignedInUserQuery{
OrgId: 1,
SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
}
err := GetFoldersForSignedInUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder1.Id)
So(query.Result[1].Id, ShouldEqual, folder2.Id)
})
Convey("should have write access to all folders and dashboards", func() {
query := m.GetDashboardPermissionsForUserQuery{
DashboardIds: []int64{folder1.Id, folder2.Id},
OrgId: 1,
UserId: adminUser.Id,
OrgRole: m.ROLE_ADMIN,
}
err := GetDashboardPermissionsForUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN)
})
})
Convey("Editor users", func() {
query := m.GetFoldersForSignedInUserQuery{
OrgId: 1,
SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR},
}
Convey("Should have write access to all dashboard folders with default ACL", func() {
err := GetFoldersForSignedInUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder1.Id)
So(query.Result[1].Id, ShouldEqual, folder2.Id)
})
Convey("should have edit access to folders with default ACL", func() {
query := m.GetDashboardPermissionsForUserQuery{
DashboardIds: []int64{folder1.Id, folder2.Id},
OrgId: 1,
UserId: editorUser.Id,
OrgRole: m.ROLE_EDITOR,
}
err := GetDashboardPermissionsForUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT)
})
Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() {
updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW)
err := GetFoldersForSignedInUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, folder2.Id)
})
})
Convey("Viewer users", func() {
query := m.GetFoldersForSignedInUserQuery{
OrgId: 1,
SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER},
}
Convey("Should have no write access to any dashboard folders with default ACL", func() {
err := GetFoldersForSignedInUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 0)
})
Convey("should have view access to folders with default ACL", func() {
query := m.GetDashboardPermissionsForUserQuery{
DashboardIds: []int64{folder1.Id, folder2.Id},
OrgId: 1,
UserId: viewerUser.Id,
OrgRole: m.ROLE_VIEWER,
}
err := GetDashboardPermissionsForUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW)
So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW)
})
Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() {
updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT)
err := GetFoldersForSignedInUser(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, folder1.Id)
})
})
})
})
}

View File

@ -5,12 +5,12 @@ import (
"testing"
"github.com/go-xorm/xorm"
. "github.com/smartystreets/goconvey/convey"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
. "github.com/smartystreets/goconvey/convey"
)
func TestDashboardDataAccess(t *testing.T) {
@ -196,18 +196,170 @@ func TestDashboardDataAccess(t *testing.T) {
})
})
Convey("Should not be able to save dashboard with same name", func() {
Convey("Should be able to save dashboards with same name in different folders", func() {
firstSaveCmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "test dash folder and title",
"tags": []interface{}{},
"uid": "randomHash",
}),
FolderId: 3,
}
err := SaveDashboard(&firstSaveCmd)
So(err, ShouldBeNil)
secondSaveCmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "test dash folder and title",
"tags": []interface{}{},
"uid": "moreRandomHash",
}),
FolderId: 1,
}
err = SaveDashboard(&secondSaveCmd)
So(err, ShouldBeNil)
})
Convey("Should not be able to save dashboard with same name in the same folder", func() {
firstSaveCmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "test dash folder and title",
"tags": []interface{}{},
"uid": "randomHash",
}),
FolderId: 3,
}
err := SaveDashboard(&firstSaveCmd)
So(err, ShouldBeNil)
secondSaveCmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "test dash folder and title",
"tags": []interface{}{},
"uid": "moreRandomHash",
}),
FolderId: 3,
}
err = SaveDashboard(&secondSaveCmd)
So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists)
})
Convey("Should not be able to save dashboard with same uid", func() {
cmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "test dash 23",
"uid": "dsfalkjngailuedt",
}),
}
err := SaveDashboard(&cmd)
So(err, ShouldBeNil)
err = SaveDashboard(&cmd)
So(err, ShouldNotBeNil)
})
Convey("Should be able to update dashboard with the same title and folder id", func() {
cmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"uid": "randomHash",
"title": "folderId",
"style": "light",
"tags": []interface{}{},
}),
FolderId: 2,
}
err := SaveDashboard(&cmd)
So(err, ShouldBeNil)
So(cmd.Result.FolderId, ShouldEqual, 2)
cmd = m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": cmd.Result.Id,
"uid": "randomHash",
"title": "folderId",
"style": "dark",
"version": cmd.Result.Version,
"tags": []interface{}{},
}),
FolderId: 2,
}
err = SaveDashboard(&cmd)
So(err, ShouldBeNil)
})
Convey("Should not be able to update using just uid", func() {
cmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"uid": savedDash.Uid,
"title": "folderId",
"version": savedDash.Version,
"tags": []interface{}{},
}),
FolderId: savedDash.FolderId,
}
err := SaveDashboard(&cmd)
So(err, ShouldEqual, m.ErrDashboardWithSameUIDExists)
})
Convey("Should be able to update using just uid with overwrite", func() {
cmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"uid": savedDash.Uid,
"title": "folderId",
"version": savedDash.Version,
"tags": []interface{}{},
}),
FolderId: savedDash.FolderId,
Overwrite: true,
}
err := SaveDashboard(&cmd)
So(err, ShouldBeNil)
})
Convey("Should retry generation of uid once if it fails.", func() {
timesCalled := 0
generateNewUid = func() string {
timesCalled += 1
if timesCalled <= 2 {
return savedDash.Uid
} else {
return util.GenerateShortUid()
}
}
cmd := m.SaveDashboardCommand{
OrgId: 1,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"title": "new dash 12334",
"tags": []interface{}{},
}),
}
err := SaveDashboard(&cmd)
So(err, ShouldNotBeNil)
So(err, ShouldBeNil)
generateNewUid = util.GenerateShortUid
})
Convey("Should be able to update dashboard and remove folderId", func() {
@ -299,205 +451,6 @@ func TestDashboardDataAccess(t *testing.T) {
})
})
Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
currentUser := createUser("viewer", "Viewer", false)
Convey("and no acls are set", func() {
Convey("should return all dashboards", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and acl is set for dashboard folder", func() {
var otherUser int64 = 999
updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
Convey("should not return folder", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
})
Convey("when the user is given permission", func() {
updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
Convey("should be able to access folder", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("when the user is an admin", func() {
Convey("should be able to access folder", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{
UserId: currentUser.Id,
OrgId: 1,
OrgRole: m.ROLE_ADMIN,
},
OrgId: 1,
DashboardIds: []int64{folder.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
})
Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
var otherUser int64 = 999
aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
removeAcl(aclId)
updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
Convey("should not return folder or child", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
})
Convey("when the user is given permission to child", func() {
updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
Convey("should be able to search for child dashboard but not folder", func() {
query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
So(query.Result[0].Id, ShouldEqual, childDash.Id)
So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("when the user is an admin", func() {
Convey("should be able to search for child dash and folder", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{
UserId: currentUser.Id,
OrgId: 1,
OrgRole: m.ROLE_ADMIN,
},
OrgId: 1,
DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 3)
So(query.Result[0].Id, ShouldEqual, folder.Id)
So(query.Result[1].Id, ShouldEqual, childDash.Id)
So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
})
})
})
})
Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
currentUser := createUser("viewer", "Viewer", false)
var rootFolderId int64 = 0
Convey("and one folder is expanded, the other collapsed", func() {
Convey("should return dashboards in root and expanded folder", func() {
query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 4)
So(query.Result[0].Id, ShouldEqual, folder1.Id)
So(query.Result[1].Id, ShouldEqual, folder2.Id)
So(query.Result[2].Id, ShouldEqual, childDash1.Id)
So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and acl is set for one dashboard folder", func() {
var otherUser int64 = 999
updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
So(movedDash.HasAcl, ShouldBeTrue)
Convey("should not return folder with acl or its children", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
OrgId: 1,
DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
So(movedDash.HasAcl, ShouldBeFalse)
Convey("should return folder without acl and its children", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
OrgId: 1,
DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 4)
So(query.Result[0].Id, ShouldEqual, folder2.Id)
So(query.Result[1].Id, ShouldEqual, childDash1.Id)
So(query.Result[2].Id, ShouldEqual, childDash2.Id)
So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
})
})
Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
So(movedDash.HasAcl, ShouldBeTrue)
Convey("should return folder without acl but not the dashboard with acl", func() {
query := &search.FindPersistedDashboardsQuery{
SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
OrgId: 1,
DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
}
err := SearchDashboards(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 3)
So(query.Result[0].Id, ShouldEqual, folder2.Id)
So(query.Result[1].Id, ShouldEqual, childDash2.Id)
So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
})
})
})
})
Convey("Given a plugin with imported dashboards", func() {
pluginId := "test-app"

View File

@ -12,7 +12,7 @@ import (
)
func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
data["title"] = dashboard.Title
data["uid"] = dashboard.Uid
saveCmd := m.SaveDashboardCommand{
OrgId: dashboard.OrgId,
@ -44,12 +44,11 @@ func TestGetDashboardVersion(t *testing.T) {
dashCmd := m.GetDashboardQuery{
OrgId: savedDash.OrgId,
Slug: savedDash.Slug,
Uid: savedDash.Uid,
}
err = GetDashboard(&dashCmd)
So(err, ShouldBeNil)
dashCmd.Result.Data.Del("uid")
eq := reflect.DeepEqual(dashCmd.Result.Data, query.Result.Data)
So(eq, ShouldEqual, true)
})

View File

@ -163,4 +163,8 @@ func addDashboardMigration(mg *Migrator) {
mg.AddMigration("Add unique index dashboard_org_id_uid", NewAddIndexMigration(dashboardV2, &Index{
Cols: []string{"org_id", "uid"}, Type: UniqueIndex,
}))
mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{
Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
}))
}

View File

@ -24,6 +24,7 @@ describe('AlertRuleList', () => {
evalData: {},
executionError: '',
dashboardUri: 'db/mygool',
canEdit: true,
},
])
);

View File

@ -147,7 +147,8 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
<div className="alert-rule-item__body">
<div className="alert-rule-item__header">
<div className="alert-rule-item__name">
<a href={ruleUrl}>{this.renderText(rule.name)}</a>
{rule.canEdit && <a href={ruleUrl}>{this.renderText(rule.name)}</a>}
{!rule.canEdit && <span>{this.renderText(rule.name)}</span>}
</div>
<div className="alert-rule-item__text">
<span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
@ -156,17 +157,30 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
</div>
{rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
</div>
<div className="alert-rule-item__actions">
<a
<button
className="btn btn-small btn-inverse alert-list__btn width-2"
title="Pausing an alert rule prevents it from executing"
onClick={this.toggleState}
disabled={!rule.canEdit}
>
<i className={stateClass} />
</a>
<a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
<i className="icon-gf icon-gf-settings" />
</a>
</button>
{rule.canEdit && (
<a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
<i className="icon-gf icon-gf-settings" />
</a>
)}
{!rule.canEdit && (
<button
className="btn btn-small btn-inverse alert-list__btn width-2"
title="Edit alert rule"
disabled={true}
>
<i className="icon-gf icon-gf-settings" />
</button>
)}
</div>
</li>
);

View File

@ -80,15 +80,16 @@ exports[`AlertRuleList should render 1 rule 1`] = `
<div
className="alert-rule-item__actions"
>
<a
<button
className="btn btn-small btn-inverse alert-list__btn width-2"
disabled={false}
onClick={[Function]}
title="Pausing an alert rule prevents it from executing"
>
<i
className="fa fa-pause"
/>
</a>
</button>
<a
className="btn btn-small btn-inverse alert-list__btn width-2"
href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"

View File

@ -1,15 +1,20 @@
import { SearchStore } from './../stores/SearchStore/SearchStore';
import { ServerStatsStore } from './../stores/ServerStatsStore/ServerStatsStore';
import { NavStore } from './../stores/NavStore/NavStore';
import { PermissionsStore } from './../stores/PermissionsStore/PermissionsStore';
import { AlertListStore } from './../stores/AlertListStore/AlertListStore';
import { ViewStore } from './../stores/ViewStore/ViewStore';
import { FolderStore } from './../stores/FolderStore/FolderStore';
interface IContainerProps {
search: typeof SearchStore.Type;
serverStats: typeof ServerStatsStore.Type;
nav: typeof NavStore.Type;
alertList: typeof AlertListStore.Type;
permissions: typeof PermissionsStore.Type;
view: typeof ViewStore.Type;
folder: typeof FolderStore.Type;
backendSrv: any;
}
export default IContainerProps;

View File

@ -0,0 +1,49 @@
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import { toJS } from 'mobx';
import IContainerProps from 'app/containers/IContainerProps';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import Permissions from 'app/core/components/Permissions/Permissions';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
@inject('nav', 'folder', 'view', 'permissions')
@observer
export class FolderPermissions extends Component<IContainerProps, any> {
constructor(props) {
super(props);
this.loadStore();
}
loadStore() {
const { nav, folder, view } = this.props;
return folder.load(view.routeParams.get('slug') as string).then(res => {
return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
});
}
render() {
const { nav, folder, permissions, backendSrv } = this.props;
if (!folder.folder || !nav.main) {
return <h2>Loading</h2>;
}
const dashboardId = folder.folder.id;
return (
<div>
<PageHeader model={nav as any} />
<div className="page-container page-body">
<div className="page-sub-heading">
<h2 className="d-inline-block">Folder Permissions</h2>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
</div>
<Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,78 @@
import React from 'react';
import { FolderSettings } from './FolderSettings';
import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv } from 'test/mocks/common';
import { shallow } from 'enzyme';
describe('FolderSettings', () => {
let wrapper;
let page;
beforeAll(() => {
backendSrv.getDashboard.mockReturnValue(
Promise.resolve({
dashboard: {
id: 1,
title: 'Folder Name',
},
meta: {
slug: 'folder-name',
canSave: true,
},
})
);
const store = RootStore.create(
{},
{
backendSrv: backendSrv,
}
);
wrapper = shallow(<FolderSettings backendSrv={backendSrv} {...store} />);
return wrapper
.dive()
.instance()
.loadStore()
.then(() => {
page = wrapper.dive();
});
});
it('should set the title input field', () => {
const titleInput = page.find('.gf-form-input');
expect(titleInput).toHaveLength(1);
expect(titleInput.prop('value')).toBe('Folder Name');
});
it('should update title and enable save button when changed', () => {
const titleInput = page.find('.gf-form-input');
const disabledSubmitButton = page.find('button[type="submit"]');
expect(disabledSubmitButton.prop('disabled')).toBe(true);
titleInput.simulate('change', { target: { value: 'New Title' } });
const updatedTitleInput = page.find('.gf-form-input');
expect(updatedTitleInput.prop('value')).toBe('New Title');
const enabledSubmitButton = page.find('button[type="submit"]');
expect(enabledSubmitButton.prop('disabled')).toBe(false);
});
it('should disable save button if title is changed back to old title', () => {
const titleInput = page.find('.gf-form-input');
titleInput.simulate('change', { target: { value: 'Folder Name' } });
const enabledSubmitButton = page.find('button[type="submit"]');
expect(enabledSubmitButton.prop('disabled')).toBe(true);
});
it('should disable save button if title is changed to empty string', () => {
const titleInput = page.find('.gf-form-input');
titleInput.simulate('change', { target: { value: '' } });
const enabledSubmitButton = page.find('button[type="submit"]');
expect(enabledSubmitButton.prop('disabled')).toBe(true);
});
});

View File

@ -0,0 +1,153 @@
import React from 'react';
import { inject, observer } from 'mobx-react';
import { toJS } from 'mobx';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import IContainerProps from 'app/containers/IContainerProps';
import { getSnapshot } from 'mobx-state-tree';
import appEvents from 'app/core/app_events';
@inject('nav', 'folder', 'view')
@observer
export class FolderSettings extends React.Component<IContainerProps, any> {
formSnapshot: any;
dashboard: any;
constructor(props) {
super(props);
this.loadStore();
}
loadStore() {
const { nav, folder, view } = this.props;
return folder.load(view.routeParams.get('slug') as string).then(res => {
this.formSnapshot = getSnapshot(folder);
this.dashboard = res.dashboard;
return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
});
}
onTitleChange(evt) {
this.props.folder.setTitle(this.getFormSnapshot().folder.title, evt.target.value);
}
getFormSnapshot() {
if (!this.formSnapshot) {
this.formSnapshot = getSnapshot(this.props.folder);
}
return this.formSnapshot;
}
save(evt) {
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
const { nav, folder, view } = this.props;
folder
.saveFolder(this.dashboard, { overwrite: false })
.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');
})
.catch(this.handleSaveFolderError);
}
delete(evt) {
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
const { folder, view } = this.props;
const title = folder.folder.title;
appEvents.emit('confirm-modal', {
title: 'Delete',
text: `Do you want to delete this folder and all its dashboards?`,
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
return this.props.folder.deleteFolder().then(() => {
appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
view.updatePathAndQuery('dashboards', '', '');
});
},
});
}
handleSaveFolderError(err) {
if (err.data && err.data.status === 'version-mismatch') {
err.isHandled = true;
appEvents.emit('confirm-modal', {
title: 'Conflict',
text: 'Someone else has updated this folder.',
text2: 'Would you still like to save this folder?',
yesText: 'Save & Overwrite',
icon: 'fa-warning',
onConfirm: () => {
this.props.folder.saveFolder(this.dashboard, { 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.']);
}
}
render() {
const { nav, folder } = this.props;
if (!folder.folder || !nav.main) {
return <h2>Loading</h2>;
}
return (
<div>
<PageHeader model={nav as any} />
<div className="page-container page-body">
<h2 className="page-sub-heading">Folder Settings</h2>
<div className="section gf-form-group">
<form name="folderSettingsForm" onSubmit={this.save.bind(this)}>
<div className="gf-form">
<label className="gf-form-label width-7">Name</label>
<input
type="text"
className="gf-form-input width-30"
value={folder.folder.title}
onChange={this.onTitleChange.bind(this)}
/>
</div>
<div className="gf-form-button-row">
<button
type="submit"
className="btn btn-success"
disabled={!folder.folder.canSave || !folder.folder.hasChanged}
>
<i className="fa fa-trash" /> Save
</button>
<button className="btn btn-danger" onClick={this.delete.bind(this)} disabled={!folder.folder.canSave}>
<i className="fa fa-trash" /> Delete
</button>
</div>
</form>
</div>
</div>
</div>
);
}
}

View File

@ -20,7 +20,7 @@ describe('ServerStats', () => {
}
);
const page = renderer.create(<ServerStats {...store} />);
const page = renderer.create(<ServerStats backendSrv={backendSrv} {...store} />);
setTimeout(() => {
expect(page.toJSON()).toMatchSnapshot();

View File

@ -41,9 +41,9 @@ exports[`ServerStats Should render table with stats 1`] = `
/>
<select
className="gf-select-nav gf-form-input"
defaultValue="/url/server-stats"
id="page-header-select-nav"
onChange={[Function]}
value="/url/server-stats"
>
<option
value="/url/server-stats"

View File

@ -4,8 +4,9 @@ import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import LoginBackground from './components/Login/LoginBackground';
import { SearchResult } from './components/search/SearchResult';
import UserPicker from './components/UserPicker/UserPicker';
import { TagFilter } from './components/TagFilter/TagFilter';
import UserPicker from './components/Picker/UserPicker';
import DashboardPermissions from './components/Permissions/DashboardPermissions';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@ -13,10 +14,17 @@ export function registerAngularDirectives() {
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('loginBackground', LoginBackground, []);
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
react2AngularDirective('tagFilter', TagFilter, [
'tags',
['onSelect', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
react2AngularDirective('dashboardPermissions', DashboardPermissions, [
'backendSrv',
'dashboardId',
'folderTitle',
'folderSlug',
'folderId',
]);
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import PageHeader from './PageHeader';
import { shallow } from 'enzyme';
describe('PageHeader', () => {
let wrapper;
describe('when the nav tree has a node with a title', () => {
beforeAll(() => {
const nav = {
main: {
icon: 'fa fa-folder-open',
id: 'node',
subTitle: 'node subtitle',
url: '',
text: 'node',
},
node: {},
};
wrapper = shallow(<PageHeader model={nav as any} />);
});
it('should render the title', () => {
const title = wrapper.find('.page-header__title');
expect(title.text()).toBe('node');
});
});
describe('when the nav tree has a node with breadcrumbs and a title', () => {
beforeAll(() => {
const nav = {
main: {
icon: 'fa fa-folder-open',
id: 'child',
subTitle: 'child subtitle',
url: '',
text: 'child',
breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }],
},
node: {},
};
wrapper = shallow(<PageHeader model={nav as any} />);
});
it('should render the title with breadcrumbs first and then title last', () => {
const title = wrapper.find('.page-header__title');
expect(title.text()).toBe('Parent / child');
const parentLink = wrapper.find('.page-header__title > a.text-link');
expect(parentLink.prop('href')).toBe('parentUrl');
});
});
});

View File

@ -1,55 +1,15 @@
import React from 'react';
import { observer } from 'mobx-react';
import { NavModel, NavModelItem } from '../../nav_model_srv';
import classNames from 'classnames';
import appEvents from 'app/core/app_events';
import { toJS } from 'mobx';
export interface IProps {
model: NavModel;
}
function TabItem(tab: NavModelItem) {
if (tab.hideFromTabs) {
return null;
}
let tabClasses = classNames({
'gf-tabs-link': true,
active: tab.active,
});
return (
<li className="gf-tabs-item" key={tab.url}>
<a className={tabClasses} target={tab.target} href={tab.url}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
}
function SelectOption(navItem: NavModelItem) {
if (navItem.hideFromTabs) {
// TODO: Rename hideFromTabs => hideFromNav
return null;
}
return (
<option key={navItem.url} value={navItem.url}>
{navItem.text}
</option>
);
}
function Navigation({ main }: { main: NavModelItem }) {
return (
<nav>
<SelectNav customCss="page-header__select-nav" main={main} />
<Tabs customCss="page-header__tabs" main={main} />
</nav>
);
}
function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string }) {
const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
const defaultSelectedItem = main.children.find(navItem => {
return navItem.active === true;
});
@ -66,26 +26,81 @@ function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string
{/* Label to make it clickable */}
<select
className="gf-select-nav gf-form-input"
defaultValue={defaultSelectedItem.url}
value={defaultSelectedItem.url}
onChange={gotoUrl}
id="page-header-select-nav"
>
{main.children.map(SelectOption)}
{main.children.map((navItem: NavModelItem) => {
if (navItem.hideFromTabs) {
// TODO: Rename hideFromTabs => hideFromNav
return null;
}
return (
<option key={navItem.url} value={navItem.url}>
{navItem.text}
</option>
);
})}
</select>
</div>
);
}
};
function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) {
return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
}
const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
return (
<ul className={`gf-tabs ${customCss}`}>
{main.children.map((tab, idx) => {
if (tab.hideFromTabs) {
return null;
}
const tabClasses = classNames({
'gf-tabs-link': true,
active: tab.active,
});
return (
<li className="gf-tabs-item" key={tab.url}>
<a className={tabClasses} target={tab.target} href={tab.url}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
})}
</ul>
);
};
const Navigation = ({ main }: { main: NavModelItem }) => {
return (
<nav>
<SelectNav customCss="page-header__select-nav" main={main} />
<Tabs customCss="page-header__tabs" main={main} />
</nav>
);
};
@observer
export default class PageHeader extends React.Component<IProps, any> {
constructor(props) {
super(props);
}
renderBreadcrumb(breadcrumbs) {
shouldComponentUpdate() {
//Hack to re-render on changed props from angular with the @observer decorator
return true;
}
renderTitle(title: string, breadcrumbs: any[]) {
if (!title && (!breadcrumbs || breadcrumbs.length === 0)) {
return null;
}
if (!breadcrumbs || breadcrumbs.length === 0) {
return <h1 className="page-header__title">{title}</h1>;
}
const breadcrumbsResult = [];
for (let i = 0; i < breadcrumbs.length; i++) {
const bc = breadcrumbs[i];
@ -99,7 +114,9 @@ export default class PageHeader extends React.Component<IProps, any> {
breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
}
}
return breadcrumbsResult;
breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>);
return <h1 className="page-header__title">{breadcrumbsResult}</h1>;
}
renderHeaderTitle(main) {
@ -111,11 +128,7 @@ export default class PageHeader extends React.Component<IProps, any> {
</span>
<div className="page-header__info-block">
{main.text && <h1 className="page-header__title">{main.text}</h1>}
{main.breadcrumbs &&
main.breadcrumbs.length > 0 && (
<h1 className="page-header__title">{this.renderBreadcrumb(main.breadcrumbs)}</h1>
)}
{this.renderTitle(main.text, main.breadcrumbs)}
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
{main.subType && (
<div className="page-header__stamps">
@ -135,12 +148,14 @@ export default class PageHeader extends React.Component<IProps, any> {
return null;
}
const main = toJS(model.main); // Convert to JS if its a mobx observable
return (
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
{this.renderHeaderTitle(model.main)}
{model.main.children && <Navigation main={model.main} />}
{this.renderHeaderTitle(main)}
{main.children && <Navigation main={main} />}
</div>
</div>
</div>

View File

@ -0,0 +1,46 @@
import React, { Component } from 'react';
import { store } from 'app/stores/store';
import Permissions from 'app/core/components/Permissions/Permissions';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
export interface IProps {
dashboardId: number;
folderId: number;
folderTitle: string;
folderSlug: string;
backendSrv: any;
}
class DashboardPermissions extends Component<IProps, any> {
permissions: any;
constructor(props) {
super(props);
this.permissions = store.permissions;
}
render() {
const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
return (
<div>
<div className="dashboard-settings__header">
<h3 className="d-inline-block">Permissions</h3>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
</div>
<Permissions
permissions={this.permissions}
isFolder={false}
dashboardId={dashboardId}
folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
backendSrv={backendSrv}
/>
</div>
);
}
}
export default DashboardPermissions;

View File

@ -0,0 +1,40 @@
import React, { Component } from 'react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
export interface IProps {
item: any;
}
export default class DisabledPermissionListItem extends Component<IProps, any> {
render() {
const { item } = this.props;
return (
<tr className="gf-form-disabled">
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
</td>
<td />
<td className="query-keyword">Can</td>
<td>
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={permissionOptions}
handlePicked={() => {}}
value={item.permission}
disabled={true}
className={'gf-form-input--form-dropdown-right'}
/>
</div>
</td>
<td>
<button className="btn btn-inverse btn-small">
<i className="fa fa-lock" />
</button>
</td>
</tr>
);
}
}

View File

@ -0,0 +1,5 @@
export interface FolderInfo {
title: string;
id: number;
slug: string;
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import Permissions from './Permissions';
import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv } from 'test/mocks/common';
import { shallow } from 'enzyme';
describe('Permissions', () => {
let wrapper;
beforeAll(() => {
backendSrv.get.mockReturnValue(
Promise.resolve([
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
{
id: 4,
dashboardId: 1,
userId: 2,
userLogin: 'danlimerick',
userEmail: 'dan.limerick@gmail.com',
permission: 4,
permissionName: 'Admin',
},
])
);
backendSrv.post = jest.fn();
const store = RootStore.create(
{},
{
backendSrv: backendSrv,
}
);
wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
return wrapper.instance().loadStore(1, true);
});
describe('when permission for a user is added', () => {
it('should save permission to db', () => {
const userItem = {
id: 2,
login: 'user2',
};
wrapper
.instance()
.userPicked(userItem)
.then(() => {
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
});
});
describe('when permission for team is added', () => {
it('should save permission to db', () => {
const teamItem = {
id: 2,
name: 'ug1',
};
wrapper
.instance()
.teamPicked(teamItem)
.then(() => {
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
});
});
});

View File

@ -0,0 +1,161 @@
import React, { Component } from 'react';
import PermissionsList from './PermissionsList';
import { observer } from 'mobx-react';
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
import { FolderInfo } from './FolderInfo';
export interface DashboardAcl {
id?: number;
dashboardId?: number;
userId?: number;
userLogin?: string;
userEmail?: string;
teamId?: number;
team?: string;
permission?: number;
permissionName?: string;
role?: string;
icon?: string;
nameHtml?: string;
inherited?: boolean;
sortName?: string;
sortRank?: number;
}
export interface IProps {
dashboardId: number;
folderInfo?: FolderInfo;
permissions?: any;
isFolder: boolean;
backendSrv: any;
}
@observer
class Permissions extends Component<IProps, any> {
constructor(props) {
super(props);
const { dashboardId, isFolder, folderInfo } = this.props;
this.permissionChanged = this.permissionChanged.bind(this);
this.typeChanged = this.typeChanged.bind(this);
this.removeItem = this.removeItem.bind(this);
this.userPicked = this.userPicked.bind(this);
this.teamPicked = this.teamPicked.bind(this);
this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
}
loadStore(dashboardId, isFolder, isInRoot = false) {
return this.props.permissions.load(dashboardId, isFolder, isInRoot);
}
permissionChanged(index: number, permission: number, permissionName: string) {
const { permissions } = this.props;
permissions.updatePermissionOnIndex(index, permission, permissionName);
}
removeItem(index: number) {
const { permissions } = this.props;
permissions.removeStoreItem(index);
}
resetNewType() {
const { permissions } = this.props;
permissions.resetNewType();
}
typeChanged(evt) {
const { value } = evt.target;
const { permissions, dashboardId } = this.props;
if (value === 'Viewer' || value === 'Editor') {
permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
this.resetNewType();
return;
}
permissions.setNewType(value);
}
userPicked(user: User) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
userId: user.id,
userLogin: user.login,
permission: 1,
dashboardId: dashboardId,
});
}
teamPicked(team: Team) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
teamId: team.id,
team: team.name,
permission: 1,
dashboardId: dashboardId,
});
}
render() {
const { permissions, folderInfo, backendSrv } = this.props;
return (
<div className="gf-form-group">
<PermissionsList
permissions={permissions.items}
removeItem={this.removeItem}
permissionChanged={this.permissionChanged}
fetching={permissions.fetching}
folderInfo={folderInfo}
/>
<div className="gf-form-inline">
<form name="addPermission" className="gf-form-group">
<h6 className="muted">Add Permission For</h6>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={permissions.newType}
onChange={this.typeChanged}
>
{aclTypes.map((option, idx) => {
return (
<option key={idx} value={option.value}>
{option.text}
</option>
);
})}
</select>
</div>
</div>
{permissions.newType === 'User' ? (
<div className="gf-form">
<UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
</div>
) : null}
{permissions.newType === 'Group' ? (
<div className="gf-form">
<TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
</div>
) : null}
</div>
</form>
{permissions.error ? (
<div className="gf-form width-17">
<span ng-if="ctrl.error" className="text-error p-l-1">
<i className="fa fa-warning" />
{permissions.error}
</span>
</div>
) : null}
</div>
</div>
);
}
}
export default Permissions;

View File

@ -0,0 +1,13 @@
import React from 'react';
export default () => {
return (
<div className="">
<h5>What are Permissions?</h5>
<p>
An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be
assigned permissions for a folder or for a single dashboard.
</p>
</div>
);
};

View File

@ -0,0 +1,64 @@
import React, { Component } from 'react';
import PermissionsListItem from './PermissionsListItem';
import DisabledPermissionsListItem from './DisabledPermissionsListItem';
import { observer } from 'mobx-react';
import { FolderInfo } from './FolderInfo';
export interface IProps {
permissions: any[];
removeItem: any;
permissionChanged: any;
fetching: boolean;
folderInfo?: FolderInfo;
}
@observer
class PermissionsList extends Component<IProps, any> {
render() {
const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props;
return (
<table className="filter-table gf-form-group">
<tbody>
<DisabledPermissionsListItem
key={0}
item={{
nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
permission: 4,
icon: 'fa fa-fw fa-street-view',
}}
/>
{permissions.map((item, idx) => {
return (
<PermissionsListItem
key={idx + 1}
item={item}
itemIndex={idx}
removeItem={removeItem}
permissionChanged={permissionChanged}
folderInfo={folderInfo}
/>
);
})}
{fetching === true && permissions.length < 1 ? (
<tr>
<td colSpan={4}>
<em>Loading permissions...</em>
</td>
</tr>
) : null}
{fetching === false && permissions.length < 1 ? (
<tr>
<td colSpan={4}>
<em>No permissions are set. Will only be accessible by admins.</em>
</td>
</tr>
) : null}
</tbody>
</table>
);
}
}
export default PermissionsList;

View File

@ -0,0 +1,65 @@
import React from 'react';
import { observer } from 'mobx-react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
const setClassNameHelper = inherited => {
return inherited ? 'gf-form-disabled' : '';
};
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
const handleRemoveItem = evt => {
evt.preventDefault();
removeItem(itemIndex);
};
const handleChangePermission = permissionOption => {
permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
};
const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
return (
<tr className={setClassNameHelper(item.inherited)}>
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
</td>
<td>
{item.inherited &&
folderInfo && (
<em className="muted no-wrap">
Inherited from folder{' '}
<a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
{folderInfo.title}
</a>{' '}
</em>
)}
{inheritedFromRoot && <em className="muted no-wrap">Default Permission</em>}
</td>
<td className="query-keyword">Can</td>
<td>
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={permissionOptions}
handlePicked={handleChangePermission}
value={item.permission}
disabled={item.inherited}
className={'gf-form-input--form-dropdown-right'}
/>
</div>
</td>
<td>
{!item.inherited ? (
<a className="btn btn-danger btn-small" onClick={handleRemoveItem}>
<i className="fa fa-remove" />
</a>
) : (
<button className="btn btn-inverse btn-small">
<i className="fa fa-lock" />
</button>
)}
</td>
</tr>
);
});

View File

@ -0,0 +1,56 @@
import React, { Component } from 'react';
export interface IProps {
onSelect: any;
onFocus: any;
option: any;
isFocused: any;
className: any;
}
class DescriptionOption extends Component<IProps, any> {
constructor(props) {
super(props);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
}
handleMouseDown(event) {
event.preventDefault();
event.stopPropagation();
this.props.onSelect(this.props.option, event);
}
handleMouseEnter(event) {
this.props.onFocus(this.props.option, event);
}
handleMouseMove(event) {
if (this.props.isFocused) {
return;
}
this.props.onFocus(this.props.option, event);
}
render() {
const { option, children, className } = this.props;
return (
<button
onMouseDown={this.handleMouseDown}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
title={option.title}
className={`description-picker-option__button btn btn-link ${className} width-19`}
>
<div className="gf-form">{children}</div>
<div className="gf-form">
<div className="muted width-17">{option.description}</div>
{className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
</div>
</button>
);
}
}
export default DescriptionOption;

View File

@ -0,0 +1,48 @@
import React, { Component } from 'react';
import Select from 'react-select';
import DescriptionOption from './DescriptionOption';
export interface IProps {
optionsWithDesc: OptionWithDescription[];
handlePicked: (permission) => void;
value: number;
disabled: boolean;
className?: string;
}
export interface OptionWithDescription {
value: any;
label: string;
description: string;
}
class DescriptionPicker extends Component<IProps, any> {
constructor(props) {
super(props);
this.state = {};
}
render() {
const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
return (
<div className="permissions-picker">
<Select
value={value}
valueKey="value"
multi={false}
clearable={false}
labelKey="label"
options={optionsWithDesc}
onChange={handlePicked}
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={DescriptionOption}
placeholder="Choose"
disabled={disabled}
/>
</div>
);
}
}
export default DescriptionPicker;

View File

@ -1,6 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer';
import UserPickerOption from './UserPickerOption';
import PickerOption from './PickerOption';
const model = {
onSelect: () => {},
@ -14,9 +14,9 @@ const model = {
className: 'class-for-user-picker',
};
describe('UserPickerOption', () => {
describe('PickerOption', () => {
it('renders correctly', () => {
const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
const tree = renderer.create(<PickerOption {...model} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,79 @@
import React, { Component } from 'react';
import Select from 'react-select';
import PickerOption from './PickerOption';
import withPicker from './withPicker';
import { debounce } from 'lodash';
export interface IProps {
backendSrv: any;
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
}
export interface Team {
id: number;
label: string;
name: string;
avatarUrl: string;
}
class TeamPicker extends Component<IProps, any> {
debouncedSearch: any;
backendSrv: any;
constructor(props) {
super(props);
this.state = {};
this.search = this.search.bind(this);
this.debouncedSearch = debounce(this.search, 300, {
leading: true,
trailing: false,
});
}
search(query?: string) {
const { toggleLoading, backendSrv } = this.props;
toggleLoading(true);
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
const teams = result.teams.map(team => {
return {
id: team.id,
label: team.name,
name: team.name,
avatarUrl: team.avatarUrl,
};
});
toggleLoading(false);
return { options: teams };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={false}
labelKey="label"
cache={false}
isLoading={isLoading}
loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..."
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={PickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default withPicker(TeamPicker);

View File

@ -8,8 +8,7 @@ const model = {
return new Promise((resolve, reject) => {});
},
},
refreshList: () => {},
teamId: '1',
handlePicked: () => {},
};
describe('UserPicker', () => {

View File

@ -0,0 +1,79 @@
import React, { Component } from 'react';
import Select from 'react-select';
import PickerOption from './PickerOption';
import withPicker from './withPicker';
import { debounce } from 'lodash';
export interface IProps {
backendSrv: any;
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
}
export interface User {
id: number;
label: string;
avatarUrl: string;
login: string;
}
class UserPicker extends Component<IProps, any> {
debouncedSearch: any;
backendSrv: any;
constructor(props) {
super(props);
this.state = {};
this.search = this.search.bind(this);
this.debouncedSearch = debounce(this.search, 300, {
leading: true,
trailing: false,
});
}
search(query?: string) {
const { toggleLoading, backendSrv } = this.props;
toggleLoading(true);
return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
const users = result.users.map(user => {
return {
id: user.id,
label: `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
login: user.login,
};
});
toggleLoading(false);
return { options: users };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={false}
labelKey="label"
cache={false}
isLoading={isLoading}
loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={PickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default withPicker(UserPicker);

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserPickerOption renders correctly 1`] = `
exports[`PickerOption renders correctly 1`] = `
<button
className="user-picker-option__button btn btn-link class-for-user-picker"
onMouseDown={[Function]}

View File

@ -0,0 +1,32 @@
import React, { Component } from 'react';
export interface IProps {
backendSrv: any;
handlePicked: (data) => void;
}
export default function withPicker(WrappedComponent) {
return class WithPicker extends Component<IProps, any> {
constructor(props) {
super(props);
this.toggleLoading = this.toggleLoading.bind(this);
this.state = {
isLoading: false,
};
}
toggleLoading(isLoading) {
this.setState(prevState => {
return {
...prevState,
isLoading: isLoading,
};
});
}
render() {
return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
}
};
}

View File

@ -6,7 +6,7 @@ describe('Popover', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<Popover placement="auto" content="Popover text">
<Popover className="test-class" placement="auto" content="Popover text">
<button>Button with Popover</button>
</Popover>
)

View File

@ -6,7 +6,7 @@ describe('Tooltip', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<Tooltip placement="auto" content="Tooltip text">
<Tooltip className="test-class" placement="auto" content="Tooltip text">
<a href="http://www.grafana.com">Link with tooltip</a>
</Tooltip>
)

View File

@ -2,7 +2,7 @@
exports[`Popover renders correctly 1`] = `
<div
className="popper__manager"
className="popper__manager test-class"
>
<div
className="popper__target"

View File

@ -2,7 +2,7 @@
exports[`Tooltip renders correctly 1`] = `
<div
className="popper__manager"
className="popper__manager test-class"
>
<div
className="popper__target"

View File

@ -4,6 +4,7 @@ import { Manager, Popper, Arrow } from 'react-popper';
interface IwithTooltipProps {
placement?: string;
content: string | ((props: any) => JSX.Element);
className?: string;
}
export default function withTooltip(WrappedComponent) {
@ -39,10 +40,10 @@ export default function withTooltip(WrappedComponent) {
}
render() {
const { content } = this.props;
const { content, className } = this.props;
return (
<Manager className="popper__manager">
<Manager className={`popper__manager ${className || ''}`}>
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
{this.state.show ? (
<Popper placement={this.state.placement} className="popper">

View File

@ -1,108 +0,0 @@
import React, { Component } from 'react';
import { debounce } from 'lodash';
import Select from 'react-select';
import UserPickerOption from './UserPickerOption';
export interface IProps {
backendSrv: any;
teamId: string;
refreshList: any;
}
export interface User {
id: number;
name: string;
login: string;
email: string;
}
class UserPicker extends Component<IProps, any> {
debouncedSearchUsers: any;
backendSrv: any;
teamId: string;
refreshList: any;
constructor(props) {
super(props);
this.backendSrv = this.props.backendSrv;
this.teamId = this.props.teamId;
this.refreshList = this.props.refreshList;
this.searchUsers = this.searchUsers.bind(this);
this.handleChange = this.handleChange.bind(this);
this.addUser = this.addUser.bind(this);
this.toggleLoading = this.toggleLoading.bind(this);
this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
leading: true,
trailing: false,
});
this.state = {
multi: false,
isLoading: false,
};
}
handleChange(user) {
this.addUser(user.id);
}
toggleLoading(isLoading) {
this.setState(prevState => {
return {
...prevState,
isLoading: isLoading,
};
});
}
addUser(userId) {
this.toggleLoading(true);
this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
this.refreshList();
this.toggleLoading(false);
});
}
searchUsers(query) {
this.toggleLoading(true);
return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
const users = result.users.map(user => {
return {
id: user.id,
label: `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
};
});
this.toggleLoading(false);
return { options: users };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={this.state.multi}
labelKey="label"
cache={false}
isLoading={this.state.isLoading}
loadOptions={this.debouncedSearchUsers}
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={this.handleChange}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={UserPickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default UserPicker;

View File

@ -5,11 +5,11 @@
<i class="gf-form-input-icon fa fa-search"></i>
</label>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}">
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
<i class="fa fa-plus"></i>
Dashboard
</a>
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId">
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId && ctrl.isEditor">
<i class="fa fa-plus"></i>
Folder
</a>
@ -95,22 +95,23 @@
</div>
</div>
<div class="search-results-container">
<dashboard-search-results
<dashboard-search-results
results="ctrl.sections"
editable="true"
on-selection-changed="ctrl.selectionChanged()"
on-tag-selected="ctrl.filterByTag($tag)" />
on-tag-selected="ctrl.filterByTag($tag)"
/>
</div>
</div>
</div>
<div ng-if="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
<div ng-if="ctrl.canSave && ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
<empty-list-cta model="{
title: 'This folder doesn\'t have any dashboards yet',
buttonIcon: 'gicon gicon-dashboard-new',
buttonLink: 'dashboard/new?folderId={{ctrl.folderId}}',
buttonTitle: 'Create Dashboard',
proTip: 'Add dashboards into your folder at ->',
proTip: 'Add/move dashboards to your folder at ->',
proTipLink: 'dashboards',
proTipLinkTitle: 'Manage dashboards',
proTipTarget: ''

View File

@ -3,22 +3,49 @@ import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
class Query {
query: string;
mode: string;
tag: any[];
starred: boolean;
skipRecent: boolean;
skipStarred: boolean;
folderIds: number[];
}
export class ManageDashboardsCtrl {
public sections: any[];
tagFilterOptions: any[];
selectedTagFilter: any;
query: any;
query: Query;
navModel: any;
selectAllChecked = false;
// enable/disable actions depending on the folders or dashboards selected
canDelete = false;
canMove = false;
// filter variables
hasFilters = false;
selectAllChecked = false;
tagFilterOptions: any[];
selectedTagFilter: any;
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
selectedStarredFilter: any;
// used when managing dashboards for a specific folder
folderId?: number;
folderSlug?: string;
// if user can add new folders and/or add new dashboards
canSave: boolean;
// if user has editor role or higher
isEditor: boolean;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv) {
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
this.isEditor = this.contextSrv.isEditor;
this.query = {
query: '',
mode: 'tree',
@ -26,6 +53,7 @@ export class ManageDashboardsCtrl {
starred: false,
skipRecent: true,
skipStarred: true,
folderIds: [],
};
if (this.folderId) {
@ -34,15 +62,26 @@ export class ManageDashboardsCtrl {
this.selectedStarredFilter = this.starredFilterOptions[0];
this.getDashboards().then(() => {
this.getTags();
this.refreshList().then(() => {
this.initTagFilter();
});
}
getDashboards() {
return this.searchSrv.search(this.query).then(result => {
return this.initDashboardList(result);
});
refreshList() {
return this.searchSrv
.search(this.query)
.then(result => {
return this.initDashboardList(result);
})
.then(() => {
if (!this.folderSlug) {
return;
}
return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
this.canSave = dash.meta.canSave;
});
});
}
initDashboardList(result: any) {
@ -176,7 +215,7 @@ export class ManageDashboardsCtrl {
appEvents.emit('alert-success', [header, msg]);
}
this.getDashboards();
this.refreshList();
});
}
@ -203,12 +242,12 @@ export class ManageDashboardsCtrl {
modalClass: 'modal--narrow',
model: {
dashboards: selectedDashboards,
afterSave: this.getDashboards.bind(this),
afterSave: this.refreshList.bind(this),
},
});
}
getTags() {
initTagFilter() {
return this.searchSrv.getDashboardTags().then(results => {
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
@ -220,11 +259,11 @@ export class ManageDashboardsCtrl {
this.query.tag.push(tag);
}
return this.getDashboards();
return this.refreshList();
}
onQueryChange() {
return this.getDashboards();
return this.refreshList();
}
onTagFilterChange() {
@ -235,7 +274,7 @@ export class ManageDashboardsCtrl {
removeTag(tag, evt) {
this.query.tag = _.without(this.query.tag, tag);
this.getDashboards();
this.refreshList();
if (evt) {
evt.stopPropagation();
evt.preventDefault();
@ -244,13 +283,13 @@ export class ManageDashboardsCtrl {
removeStarred() {
this.query.starred = false;
return this.getDashboards();
return this.refreshList();
}
onStarredFilterChange() {
this.query.starred = this.selectedStarredFilter.text === 'Yes';
this.selectedStarredFilter = this.starredFilterOptions[0];
return this.getDashboards();
return this.refreshList();
}
onSelectAllChanged() {
@ -272,7 +311,7 @@ export class ManageDashboardsCtrl {
this.query.query = '';
this.query.tag = [];
this.query.starred = false;
this.getDashboards();
this.refreshList();
}
createDashboardUrl() {
@ -295,6 +334,7 @@ export function manageDashboardsDirective() {
controllerAs: 'ctrl',
scope: {
folderId: '=',
folderSlug: '=',
},
};
}

View File

@ -10,8 +10,9 @@ export class InvitedCtrl {
$scope.navModel = {
main: {
icon: 'gicon gicon-branding',
text: 'Invite',
subTitle: 'Register your Grafana account',
breadcrumbs: [{ title: 'Login', url: '/login' }, { title: 'Invite' }],
breadcrumbs: [{ title: 'Login', url: '/login' }],
},
};

View File

@ -16,8 +16,9 @@ export class ResetPasswordCtrl {
$scope.navModel = {
main: {
icon: 'gicon gicon-branding',
text: 'Reset Password',
subTitle: 'Reset your Grafana password',
breadcrumbs: [{ title: 'Login', url: 'login' }, { title: 'Reset Password' }],
breadcrumbs: [{ title: 'Login', url: 'login' }],
},
};

View File

@ -1,5 +1,4 @@
import './directives/dash_class';
import './directives/dash_edit_link';
import './directives/dropdown_typeahead';
import './directives/metric_segment';
import './directives/misc';

View File

@ -1,150 +0,0 @@
define([
'jquery',
'angular',
'../core_module',
'lodash',
],
function ($, angular, coreModule, _) {
'use strict';
var editViewMap = {
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
'templating': { src: 'public/app/features/templating/partials/editor.html'},
'history': { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
'import': { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
'new-folder': {
isModal: true,
html: '<folder-modal dismiss="dismiss()"></folder-modal>',
modalClass: 'modal--narrow'
}
};
coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
return {
restrict: 'A',
link: function(scope, elem) {
var editorScope;
var modalScope;
var lastEditView;
function hideEditorPane(hideToShowOtherView) {
if (editorScope) {
editorScope.dismiss(hideToShowOtherView);
}
}
function showEditorPane(evt, options) {
if (options.editview) {
_.defaults(options, editViewMap[options.editview]);
}
if (lastEditView && lastEditView === options.editview) {
hideEditorPane(false);
return;
}
hideEditorPane(true);
lastEditView = options.editview;
editorScope = options.scope ? options.scope.$new() : scope.$new();
editorScope.dismiss = function(hideToShowOtherView) {
if (modalScope) {
modalScope.dismiss();
modalScope = null;
}
editorScope.$destroy();
lastEditView = null;
editorScope = null;
elem.removeClass('dash-edit-view--open');
if (!hideToShowOtherView) {
setTimeout(function() {
elem.empty();
}, 250);
}
if (options.editview) {
var urlParams = $location.search();
if (options.editview === urlParams.editview) {
delete urlParams.editview;
// even though we always are in apply phase here
// some angular bug is causing location search updates to
// not happen always so this is a hack fix or that
setTimeout(function() {
$rootScope.$apply(function() {
$location.search(urlParams);
});
});
}
}
};
if (options.isModal) {
modalScope = $rootScope.$new();
modalScope.$on("$destroy", function() {
editorScope.dismiss();
});
$rootScope.appEvent('show-modal', {
templateHtml: options.html,
scope: modalScope,
backdrop: 'static',
modalClass: options.modalClass,
});
return;
}
var view;
if (options.src) {
view = angular.element(document.createElement('div'));
view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
} else {
view = angular.element(document.createElement('div'));
view.addClass('tabbed-view');
view.html(options.html);
}
$compile(view)(editorScope);
setTimeout(function() {
elem.empty();
elem.append(view);
setTimeout(function() {
elem.addClass('dash-edit-view--open');
}, 10);
}, 10);
}
scope.$watch("ctrl.dashboardViewState.state.editview", function(newValue, oldValue) {
if (newValue) {
showEditorPane(null, {editview: newValue});
} else if (oldValue) {
if (lastEditView === oldValue) {
hideEditorPane();
}
}
});
scope.$on("$destroy", hideEditorPane);
scope.onAppEvent('hide-dash-editor', function() {
hideEditorPane(false);
});
scope.onAppEvent('show-dash-editor', showEditorPane);
scope.onAppEvent('panel-fullscreen-enter', function() {
scope.appEvent('hide-dash-editor');
});
}
};
});
});

View File

@ -10,7 +10,7 @@ export class BridgeSrv {
private fullPageReloadRoutes;
/** @ngInject */
constructor(private $location, private $timeout, private $window, private $rootScope) {
constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
this.appSubUrl = config.appSubUrl;
this.fullPageReloadRoutes = ['/logout'];
}
@ -29,14 +29,14 @@ export class BridgeSrv {
this.$rootScope.$on('$routeUpdate', (evt, data) => {
let angularUrl = this.$location.url();
if (store.view.currentUrl !== angularUrl) {
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
}
});
this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
let angularUrl = this.$location.url();
if (store.view.currentUrl !== angularUrl) {
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
}
});

View File

@ -10,7 +10,7 @@ describe('BridgeSrv', () => {
let searchSrv;
beforeEach(() => {
searchSrv = new BridgeSrv(null, null, null, null);
searchSrv = new BridgeSrv(null, null, null, null, null);
});
describe('With /subUrl as appSubUrl', () => {

View File

@ -49,7 +49,7 @@ describe('ManageDashboards', () => {
},
];
ctrl = createCtrlWithStubs(response);
return ctrl.getDashboards();
return ctrl.refreshList();
});
it('should set checked to false on all sections and children', () => {
@ -88,7 +88,7 @@ describe('ManageDashboards', () => {
];
ctrl = createCtrlWithStubs(response);
ctrl.folderId = 410;
return ctrl.getDashboards();
return ctrl.refreshList();
});
it('should set hide header to true on section', () => {
@ -137,7 +137,7 @@ describe('ManageDashboards', () => {
ctrl.canMove = true;
ctrl.canDelete = true;
ctrl.selectAllChecked = true;
return ctrl.getDashboards();
return ctrl.refreshList();
});
it('should set checked to false on all sections and children', () => {
@ -567,5 +567,5 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
},
};
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub);
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub, { isEditor: true });
}

View File

@ -12,7 +12,11 @@ export function toUrlParams(a) {
let add = function(k, v) {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
if (typeof v !== 'boolean') {
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
} else {
s[s.length] = encodeURIComponent(k);
}
};
let buildParams = function(prefix, obj) {

View File

@ -54,7 +54,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
`;
// Show edit icon only for users with at least Editor role
if (event.id && contextSrv.isEditor) {
if (event.id && dashboard.meta.canEdit) {
header += `
<span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
<i class="fa fa-pencil-square"></i>

View File

@ -1,126 +0,0 @@
<div class="modal-body">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-lock"></i>
<span class="p-l-1">Permissions</span>
</h2>
<a class="modal-header-close" ng-click="ctrl.dismiss();">
<i class="fa fa-remove"></i>
</a>
</div>
<div class="modal-content">
<table class="filter-table gf-form-group">
<tr ng-repeat="acl in ctrl.items" ng-class="{'gf-form-disabled': acl.inherited}">
<td style="width: 100%;">
<i class="{{acl.icon}}"></i>
<span ng-bind-html="acl.nameHtml"></span>
</td>
<td>
<em class="muted no-wrap" ng-show="acl.inherited">Inherited from folder</em>
</td>
<td class="query-keyword">Can</td>
<td>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="acl.permission" ng-options="p.value as p.text for p in ctrl.permissionOptions" ng-change="ctrl.permissionChanged(acl)" ng-disabled="acl.inherited"></select>
</div>
</td>
<td>
<a class="btn btn-inverse btn-small" ng-click="ctrl.removeItem($index)" ng-hide="acl.inherited">
<i class="fa fa-remove"></i>
</a>
</td>
</tr>
<tr ng-show="ctrl.aclItems.length === 0">
<td colspan="4">
<em>No permissions. Will only be accessible by admins.</em>
</td>
</tr>
</table>
<div class="gf-form-inline">
<form name="addPermission" class="gf-form-group">
<h6 class="muted">Add Permission For</h6>
<div class="gf-form-inline">
<div class="gf-form">
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.newType" ng-options="p.value as p.text for p in ctrl.aclTypes" ng-change="ctrl.typeChanged()"></select>
</div>
</div>
<div class="gf-form" ng-show="ctrl.newType === 'User'">
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
</div>
<div class="gf-form" ng-show="ctrl.newType === 'Group'">
<team-picker team-picked="ctrl.groupPicked($group)"></team-picker>
</div>
</div>
</form>
<div class="gf-form width-17">
<span ng-if="ctrl.error" class="text-error p-l-1">
<i class="fa fa-warning"></i>
{{ctrl.error}}
</span>
</div>
</div>
<div class="gf-form-button-row text-center">
<button type="button" class="btn btn-danger" ng-disabled="!ctrl.canUpdate" ng-click="ctrl.update()">
Update Permissions
</button>
<a class="btn-text" ng-click="ctrl.dismiss();">Close</a>
</div>
</div>
</div>
<!-- <br> -->
<!-- <br> -->
<!-- <br> -->
<!-- -->
<!-- <div class="permissionlist"> -->
<!-- <div class="permissionlist__section"> -->
<!-- <div class="permissionlist__section&#45;header"> -->
<!-- <h6>Permissions</h6> -->
<!-- </div> -->
<!-- <table class="filter&#45;table form&#45;inline"> -->
<!-- <thead> -->
<!-- <tr> -->
<!-- <th style="width: 50px;"></th> -->
<!-- <th>Name</th> -->
<!-- <th style="width: 220px;">Permission</th> -->
<!-- <th style="width: 120px"></th> -->
<!-- </tr> -->
<!-- </thead> -->
<!-- <tbody> -->
<!-- <tr ng&#45;repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
<!-- <td><i class="fa fa&#45;fw fa&#45;user"></i></td> -->
<!-- <td>{{permission.userLogin}}</td> -->
<!-- <td class="text&#45;right"> -->
<!-- <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
<!-- <i class="fa fa&#45;remove"></i> -->
<!-- </a> -->
<!-- </td> -->
<!-- </tr> -->
<!-- <tr ng&#45;repeat="permission in ctrl.teamPermissions" class="permissionlist__item"> -->
<!-- <td><i class="fa fa&#45;fw fa&#45;users"></i></td> -->
<!-- <td>{{permission.team}}</td> -->
<!-- <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="permission.permissions" ng&#45;options="p.value as p.text for p in ctrl.permissionTypeOptions" ng&#45;change="ctrl.updatePermission(permission)"></select></td> -->
<!-- <td class="text&#45;right"> -->
<!-- <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
<!-- <i class="fa fa&#45;remove"></i> -->
<!-- </a> -->
<!-- </td> -->
<!-- </tr> -->
<!-- <tr ng&#45;repeat="role in ctrl.roles" class="permissionlist__item"> -->
<!-- <td></td> -->
<!-- <td>{{role.name}}</td> -->
<!-- <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="role.permissions" ng&#45;options="p.value as p.text for p in ctrl.roleOptions" ng&#45;change="ctrl.updatePermission(role)"></select></td> -->
<!-- <td class="text&#45;right"> -->
<!-- -->
<!-- </td> -->
<!-- </tr> -->
<!-- </tbody> -->
<!-- </table> -->
<!-- </div> -->
<!-- </div> -->
<!-- </div> -->

View File

@ -1,201 +0,0 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
export class AclCtrl {
dashboard: any;
items: DashboardAcl[];
permissionOptions = [{ value: 1, text: 'View' }, { value: 2, text: 'Edit' }, { value: 4, text: 'Admin' }];
aclTypes = [
{ value: 'Group', text: 'Team' },
{ value: 'User', text: 'User' },
{ value: 'Viewer', text: 'Everyone With Viewer Role' },
{ value: 'Editor', text: 'Everyone With Editor Role' },
];
dismiss: () => void;
newType: string;
canUpdate: boolean;
error: string;
readonly duplicateError = 'This permission exists already.';
/** @ngInject */
constructor(private backendSrv, dashboardSrv, private $sce, private $scope) {
this.items = [];
this.resetNewType();
this.dashboard = dashboardSrv.getCurrent();
this.get(this.dashboard.id);
}
resetNewType() {
this.newType = 'Group';
}
get(dashboardId: number) {
return this.backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`).then(result => {
this.items = _.map(result, this.prepareViewModel.bind(this));
this.sortItems();
});
}
sortItems() {
this.items = _.orderBy(this.items, ['sortRank', 'sortName'], ['desc', 'asc']);
}
prepareViewModel(item: DashboardAcl): DashboardAcl {
item.inherited = !this.dashboard.meta.isFolder && this.dashboard.id !== item.dashboardId;
item.sortRank = 0;
if (item.userId > 0) {
item.icon = 'fa fa-fw fa-user';
item.nameHtml = this.$sce.trustAsHtml(item.userLogin);
item.sortName = item.userLogin;
item.sortRank = 10;
} else if (item.teamId > 0) {
item.icon = 'fa fa-fw fa-users';
item.nameHtml = this.$sce.trustAsHtml(item.team);
item.sortName = item.team;
item.sortRank = 20;
} else if (item.role) {
item.icon = 'fa fa-fw fa-street-view';
item.nameHtml = this.$sce.trustAsHtml(`Everyone with <span class="query-keyword">${item.role}</span> Role`);
item.sortName = item.role;
item.sortRank = 30;
if (item.role === 'Viewer') {
item.sortRank += 1;
}
}
if (item.inherited) {
item.sortRank += 100;
}
return item;
}
update() {
var updated = [];
for (let item of this.items) {
if (item.inherited) {
continue;
}
updated.push({
id: item.id,
userId: item.userId,
teamId: item.teamId,
role: item.role,
permission: item.permission,
});
}
return this.backendSrv.post(`/api/dashboards/id/${this.dashboard.id}/acl`, { items: updated }).then(() => {
return this.dismiss();
});
}
typeChanged() {
if (this.newType === 'Viewer' || this.newType === 'Editor') {
this.addNewItem({ permission: 1, role: this.newType });
this.canUpdate = true;
this.resetNewType();
}
}
permissionChanged() {
this.canUpdate = true;
}
addNewItem(item) {
if (!this.isValid(item)) {
return;
}
this.error = '';
item.dashboardId = this.dashboard.id;
this.items.push(this.prepareViewModel(item));
this.sortItems();
this.canUpdate = true;
}
isValid(item) {
const dupe = _.find(this.items, it => {
return this.isDuplicate(it, item);
});
if (dupe) {
this.error = this.duplicateError;
return false;
}
return true;
}
isDuplicate(origItem, newItem) {
if (origItem.inherited) {
return false;
}
return (
(origItem.role && newItem.role && origItem.role === newItem.role) ||
(origItem.userId && newItem.userId && origItem.userId === newItem.userId) ||
(origItem.teamId && newItem.teamId && origItem.teamId === newItem.teamId)
);
}
userPicked(user) {
this.addNewItem({ userId: user.id, userLogin: user.login, permission: 1 });
this.$scope.$broadcast('user-picker-reset');
}
groupPicked(group) {
this.addNewItem({ teamId: group.id, team: group.name, permission: 1 });
this.$scope.$broadcast('team-picker-reset');
}
removeItem(index) {
this.items.splice(index, 1);
this.canUpdate = true;
}
}
export function dashAclModal() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/acl/acl.html',
controller: AclCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
dismiss: '&',
},
};
}
export interface FormModel {
dashboardId: number;
userId?: number;
teamId?: number;
PermissionType: number;
}
export interface DashboardAcl {
id?: number;
dashboardId?: number;
userId?: number;
userLogin?: string;
userEmail?: string;
teamId?: number;
team?: string;
permission?: number;
permissionName?: string;
role?: string;
icon?: string;
nameHtml?: string;
inherited?: boolean;
sortName?: string;
sortRank?: number;
}
coreModule.directive('dashAclModal', dashAclModal);

View File

@ -1,188 +0,0 @@
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
import { AclCtrl } from '../acl';
describe('AclCtrl', () => {
const ctx: any = {};
const backendSrv = {
get: sinon.stub().returns(Promise.resolve([])),
post: sinon.stub().returns(Promise.resolve([])),
};
const dashboardSrv = {
getCurrent: sinon.stub().returns({ id: 1, meta: { isFolder: false } }),
};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.controllers'));
beforeEach(
angularMocks.inject(($rootScope, $controller, $q, $compile) => {
ctx.$q = $q;
ctx.scope = $rootScope.$new();
AclCtrl.prototype.dashboard = { dashboard: { id: 1 } };
ctx.ctrl = $controller(
AclCtrl,
{
$scope: ctx.scope,
backendSrv: backendSrv,
dashboardSrv: dashboardSrv,
},
{
dismiss: () => {
return;
},
}
);
})
);
describe('when permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
const userItem = {
id: 2,
login: 'user2',
};
ctx.ctrl.userPicked(userItem);
const teamItem = {
id: 2,
name: 'ug1',
};
ctx.ctrl.groupPicked(teamItem);
ctx.ctrl.newType = 'Editor';
ctx.ctrl.typeChanged();
ctx.ctrl.newType = 'Viewer';
ctx.ctrl.typeChanged();
});
it('should sort the result by role, team and user', () => {
expect(ctx.ctrl.items[0].role).to.eql('Viewer');
expect(ctx.ctrl.items[1].role).to.eql('Editor');
expect(ctx.ctrl.items[2].teamId).to.eql(2);
expect(ctx.ctrl.items[3].userId).to.eql(2);
});
it('should save permissions to db', done => {
ctx.ctrl.update().then(() => {
done();
});
expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/dashboards/id/1/acl');
expect(backendSrv.post.getCall(0).args[1].items[0].role).to.eql('Viewer');
expect(backendSrv.post.getCall(0).args[1].items[0].permission).to.eql(1);
expect(backendSrv.post.getCall(0).args[1].items[1].role).to.eql('Editor');
expect(backendSrv.post.getCall(0).args[1].items[1].permission).to.eql(1);
expect(backendSrv.post.getCall(0).args[1].items[2].teamId).to.eql(2);
expect(backendSrv.post.getCall(0).args[1].items[2].permission).to.eql(1);
expect(backendSrv.post.getCall(0).args[1].items[3].userId).to.eql(2);
expect(backendSrv.post.getCall(0).args[1].items[3].permission).to.eql(1);
});
});
describe('when duplicate role permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
ctx.ctrl.newType = 'Editor';
ctx.ctrl.typeChanged();
ctx.ctrl.newType = 'Editor';
ctx.ctrl.typeChanged();
});
it('should throw a validation error', () => {
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
});
it('should not add the duplicate permission', () => {
expect(ctx.ctrl.items.length).to.eql(1);
});
});
describe('when duplicate user permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
const userItem = {
id: 2,
login: 'user2',
};
ctx.ctrl.userPicked(userItem);
ctx.ctrl.userPicked(userItem);
});
it('should throw a validation error', () => {
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
});
it('should not add the duplicate permission', () => {
expect(ctx.ctrl.items.length).to.eql(1);
});
});
describe('when duplicate team permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
const teamItem = {
id: 2,
name: 'ug1',
};
ctx.ctrl.groupPicked(teamItem);
ctx.ctrl.groupPicked(teamItem);
});
it('should throw a validation error', () => {
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
});
it('should not add the duplicate permission', () => {
expect(ctx.ctrl.items.length).to.eql(1);
});
});
describe('when one inherited and one not inherited team permission are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
const inheritedTeamItem = {
id: 2,
name: 'ug1',
dashboardId: -1,
};
ctx.ctrl.items.push(inheritedTeamItem);
const teamItem = {
id: 2,
name: 'ug1',
};
ctx.ctrl.groupPicked(teamItem);
});
it('should not throw a validation error', () => {
expect(ctx.ctrl.error).to.eql('');
});
it('should add both permissions', () => {
expect(ctx.ctrl.items.length).to.eql(2);
});
});
});

View File

@ -23,7 +23,6 @@ import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective';
import './dashgrid/PanelLoader';
import './dashgrid/RowOptions';
import './acl/acl';
import './folder_picker/folder_picker';
import './move_to_folder_modal/move_to_folder';
import './settings/settings';
@ -31,14 +30,12 @@ import './settings/settings';
import coreModule from 'app/core/core_module';
import { DashboardListCtrl } from './dashboard_list_ctrl';
import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
import { FolderPermissionsCtrl } from './folder_permissions_ctrl';
import { FolderSettingsCtrl } from './folder_settings_ctrl';
import { DashboardImportCtrl } from './dashboard_import_ctrl';
import { CreateFolderCtrl } from './create_folder_ctrl';
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
coreModule.controller('FolderPermissionsCtrl', FolderPermissionsCtrl);
coreModule.controller('FolderSettingsCtrl', FolderSettingsCtrl);
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

View File

@ -571,6 +571,7 @@ export class DashboardModel {
if (row.collapsed) {
row.collapsed = false;
let hasRepeat = false;
if (row.panels.length > 0) {
// Use first panel to figure out if it was moved or pushed
@ -591,6 +592,10 @@ export class DashboardModel {
// update insert post and y max
insertPos += 1;
yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
if (panel.repeat) {
hasRepeat = true;
}
}
const pushDownAmount = yMax - row.gridPos.y;
@ -601,6 +606,10 @@ export class DashboardModel {
}
row.panels = [];
if (hasRepeat) {
this.processRepeats();
}
}
// sort panels

View File

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

View File

@ -1,5 +1,3 @@
import _ from 'lodash';
export class FolderPageLoader {
constructor(private backendSrv) {}
@ -11,7 +9,7 @@ export class FolderPageLoader {
subTitle: 'Manage folder dashboards & permissions',
url: '',
text: '',
breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }, { title: ' ' }],
breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }],
children: [
{
active: activeChildId === 'manage-folder-dashboards',
@ -41,25 +39,21 @@ export class FolderPageLoader {
return this.backendSrv.getDashboardByUid(uid).then(result => {
ctrl.folderId = result.dashboard.id;
const folderTitle = result.dashboard.title;
ctrl.navModel.main.text = '';
ctrl.navModel.main.breadcrumbs = [{ title: 'Dashboards', url: 'dashboards' }, { title: folderTitle }];
const folderUrl = result.meta.url;
ctrl.navModel.main.text = folderTitle;
const dashTab = _.find(ctrl.navModel.main.children, {
id: 'manage-folder-dashboards',
});
const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
dashTab.url = folderUrl;
const permTab = _.find(ctrl.navModel.main.children, {
id: 'manage-folder-permissions',
});
permTab.url = folderUrl + '/permissions';
if (result.meta.canAdmin) {
const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
permTab.url = folderUrl + '/permissions';
const settingsTab = _.find(ctrl.navModel.main.children, {
id: 'manage-folder-settings',
});
settingsTab.url = folderUrl + '/settings';
const settingsTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-settings');
settingsTab.url = folderUrl + '/settings';
} else {
ctrl.navModel.main.children = [dashTab];
}
return result;
});

View File

@ -4,6 +4,8 @@ export class FolderPermissionsCtrl {
navModel: any;
folderId: number;
uid: string;
dashboard: any;
meta: any;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
@ -14,6 +16,9 @@ export class FolderPermissionsCtrl {
if ($location.path() !== folder.meta.url) {
$location.path(`${folder.meta.url}/permissions`).replace();
}
this.dashboard = folder.dashboard;
this.meta = folder.meta;
});
}
}

View File

@ -30,12 +30,7 @@ export class FolderPickerCtrl {
}
getOptions(query) {
var params = {
query: query,
type: 'dash-folder',
};
return this.backendSrv.search(params).then(result => {
return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
if (
query === '' ||
query.toLowerCase() === 'r' ||
@ -120,6 +115,9 @@ export class FolderPickerCtrl {
if (this.initialFolderId && this.initialFolderId > 0) {
this.getOptions('').then(result => {
this.folder = _.find(result, { value: this.initialFolderId });
if (!this.folder) {
this.folder = { text: this.initialTitle, value: this.initialFolderId };
}
this.onFolderLoad();
});
} else {

View File

@ -1,5 +1,5 @@
<page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<manage-dashboards ng-if="ctrl.folderId" folder-id="ctrl.folderId" />
</div>
<manage-dashboards ng-if="ctrl.folderId && ctrl.folderSlug" folder-id="ctrl.folderId" folder-slug="ctrl.folderSlug" />
</div>

View File

@ -1,7 +1,7 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<h2 class="page-sub-heading">
Coming soon! Permissions will be added in Grafana 5.0 beta.
</h2>
<dashboard-permissions ng-if="ctrl.dashboard && ctrl.meta"
dashboardId="ctrl.dashboard.id"
/>
</div>

View File

@ -95,6 +95,16 @@
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
<dashboard-permissions ng-if="ctrl.dashboard"
dashboardId="ctrl.dashboard.id"
backendSrv="ctrl.backendSrv"
folderTitle="ctrl.dashboard.meta.folderTitle"
folderSlug="ctrl.dashboard.meta.folderSlug"
folderId="ctrl.dashboard.meta.folderId"
/>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">
<h3 class="dashboard-settings__header">Settings view not found</h3>

View File

@ -70,6 +70,14 @@ export class SettingsCtrl {
});
}
if (this.dashboard.id && this.dashboard.meta.canAdmin) {
this.sections.push({
title: 'Permissions',
id: 'permissions',
icon: 'fa fa-fw fa-lock',
});
}
if (this.dashboard.meta.canMakeEditable) {
this.sections.push({
title: 'General',
@ -183,6 +191,7 @@ export class SettingsCtrl {
onFolderChange(folder) {
this.dashboard.meta.folderId = folder.id;
this.dashboard.meta.folderTitle = folder.title;
this.dashboard.meta.folderSlug = folder.slug;
}
}

View File

@ -4,6 +4,57 @@ import { expect } from 'test/lib/common';
jest.mock('app/core/services/context_srv', () => ({}));
describe('given dashboard with panel repeat', function() {
var dashboard;
beforeEach(function() {
let dashboardJSON = {
panels: [
{ id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
{ id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } },
],
templating: {
list: [
{
name: 'apps',
current: {
text: 'se1, se2, se3',
value: ['se1', 'se2', 'se3'],
},
options: [
{ text: 'se1', value: 'se1', selected: true },
{ text: 'se2', value: 'se2', selected: true },
{ text: 'se3', value: 'se3', selected: true },
{ text: 'se4', value: 'se4', selected: false },
],
},
],
},
};
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
});
it('should repeat panels when row is expanding', function() {
expect(dashboard.panels.length).toBe(4);
// toggle row
dashboard.toggleRow(dashboard.panels[0]);
expect(dashboard.panels.length).toBe(1);
// change variable
dashboard.templating.list[0].options[2].selected = false;
dashboard.templating.list[0].current = {
text: 'se1, se2',
value: ['se1', 'se2'],
};
// toggle row back
dashboard.toggleRow(dashboard.panels[0]);
expect(dashboard.panels.length).toBe(3);
});
});
describe('given dashboard with panel repeat in horizontal direction', function() {
var dashboard;

View File

@ -17,7 +17,7 @@
<div class="grafana-info-box">
<h5>What are Dashboard Links?</h5>
<p>
Dashboad Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
Dashboard Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
</p>
</div>
</div>

View File

@ -29,7 +29,11 @@
<form name="ctrl.addMemberForm" class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-10">Add member</span>
<select-user-picker backendSrv="ctrl.backendSrv" teamId="ctrl.$routeParams.id" refreshList="ctrl.get" teamMembers="ctrl.teamMembers"></select-user-picker>
<!--
Old picker
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
-->
<select-user-picker handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
</div>
</form>
@ -60,3 +64,4 @@
This team has no members yet.
</em>
</div>

View File

@ -8,6 +8,7 @@ export default class TeamDetailsCtrl {
/** @ngInject **/
constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
this.userPicked = this.userPicked.bind(this);
this.get = this.get.bind(this);
this.get();
}

View File

@ -12,6 +12,7 @@ export class PlaylistSearchCtrl {
$timeout(() => {
this.query.query = '';
this.query.type = 'dash-db';
this.searchDashboards();
}, 100);
}

View File

@ -3,6 +3,7 @@ import './plugin_page_ctrl';
import './plugin_list_ctrl';
import './import_list/import_list';
import './ds_edit_ctrl';
import './ds_dashboards_ctrl';
import './ds_list_ctrl';
import './datasource_srv';
import './plugin_component';

Some files were not shown because too many files have changed in this diff Show More