mirror of
https://github.com/grafana/grafana.git
synced 2025-01-19 13:03:32 -06:00
Merge branch '7883_new_url_structure' into 7883_frontend_step2
This commit is contained in:
commit
ef90b3e49c
@ -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)
|
||||
|
||||
|
@ -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" >}})
|
||||
|
@ -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",
|
||||
|
@ -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
97
pkg/api/alerting_test.go
Normal 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)
|
||||
})
|
||||
}
|
@ -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
242
pkg/api/annotations_test.go
Normal 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)
|
||||
})
|
||||
}
|
@ -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
105
pkg/api/common_test.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -159,10 +159,6 @@ type SetAlertStateCommand struct {
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type DeleteAlertCommand struct {
|
||||
AlertId int64
|
||||
}
|
||||
|
||||
//Queries
|
||||
type GetAlertsQuery struct {
|
||||
OrgId int64
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
// }
|
||||
//}
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
349
pkg/services/sqlstore/dashboard_folder_test.go
Normal file
349
pkg/services/sqlstore/dashboard_folder_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
@ -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"
|
||||
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ describe('AlertRuleList', () => {
|
||||
evalData: {},
|
||||
executionError: '',
|
||||
dashboardUri: 'db/mygool',
|
||||
canEdit: true,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
49
public/app/containers/ManageDashboards/FolderPermissions.tsx
Normal file
49
public/app/containers/ManageDashboards/FolderPermissions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
153
public/app/containers/ManageDashboards/FolderSettings.tsx
Normal file
153
public/app/containers/ManageDashboards/FolderSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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"
|
||||
|
@ -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',
|
||||
]);
|
||||
}
|
||||
|
53
public/app/core/components/PageHeader/PageHeader.jest.tsx
Normal file
53
public/app/core/components/PageHeader/PageHeader.jest.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
5
public/app/core/components/Permissions/FolderInfo.ts
Normal file
5
public/app/core/components/Permissions/FolderInfo.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface FolderInfo {
|
||||
title: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
}
|
73
public/app/core/components/Permissions/Permissions.jest.tsx
Normal file
73
public/app/core/components/Permissions/Permissions.jest.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
161
public/app/core/components/Permissions/Permissions.tsx
Normal file
161
public/app/core/components/Permissions/Permissions.tsx
Normal 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;
|
13
public/app/core/components/Permissions/PermissionsInfo.tsx
Normal file
13
public/app/core/components/Permissions/PermissionsInfo.tsx
Normal 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>
|
||||
);
|
||||
};
|
64
public/app/core/components/Permissions/PermissionsList.tsx
Normal file
64
public/app/core/components/Permissions/PermissionsList.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
});
|
56
public/app/core/components/Picker/DescriptionOption.tsx
Normal file
56
public/app/core/components/Picker/DescriptionOption.tsx
Normal 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;
|
48
public/app/core/components/Picker/DescriptionPicker.tsx
Normal file
48
public/app/core/components/Picker/DescriptionPicker.tsx
Normal 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;
|
@ -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();
|
||||
});
|
||||
});
|
79
public/app/core/components/Picker/TeamPicker.tsx
Normal file
79
public/app/core/components/Picker/TeamPicker.tsx
Normal 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);
|
@ -8,8 +8,7 @@ const model = {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
refreshList: () => {},
|
||||
teamId: '1',
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('UserPicker', () => {
|
79
public/app/core/components/Picker/UserPicker.tsx
Normal file
79
public/app/core/components/Picker/UserPicker.tsx
Normal 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);
|
@ -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]}
|
32
public/app/core/components/Picker/withPicker.tsx
Normal file
32
public/app/core/components/Picker/withPicker.tsx
Normal 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} />;
|
||||
}
|
||||
};
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Popover renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager"
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Tooltip renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager"
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
|
@ -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">
|
||||
|
@ -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;
|
@ -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: ''
|
||||
|
@ -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: '=',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import './directives/dash_class';
|
||||
import './directives/dash_edit_link';
|
||||
import './directives/dropdown_typeahead';
|
||||
import './directives/metric_segment';
|
||||
import './directives/misc';
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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', () => {
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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-header"> -->
|
||||
<!-- <h6>Permissions</h6> -->
|
||||
<!-- </div> -->
|
||||
<!-- <table class="filter-table form-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-repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
|
||||
<!-- <td><i class="fa fa-fw fa-user"></i></td> -->
|
||||
<!-- <td>{{permission.userLogin}}</td> -->
|
||||
<!-- <td class="text-right"> -->
|
||||
<!-- <a ng-click="ctrl.removePermission(permission)" class="btn btn-danger btn-small"> -->
|
||||
<!-- <i class="fa fa-remove"></i> -->
|
||||
<!-- </a> -->
|
||||
<!-- </td> -->
|
||||
<!-- </tr> -->
|
||||
<!-- <tr ng-repeat="permission in ctrl.teamPermissions" class="permissionlist__item"> -->
|
||||
<!-- <td><i class="fa fa-fw fa-users"></i></td> -->
|
||||
<!-- <td>{{permission.team}}</td> -->
|
||||
<!-- <td><select class="gf-form-input gf-size-auto" ng-model="permission.permissions" ng-options="p.value as p.text for p in ctrl.permissionTypeOptions" ng-change="ctrl.updatePermission(permission)"></select></td> -->
|
||||
<!-- <td class="text-right"> -->
|
||||
<!-- <a ng-click="ctrl.removePermission(permission)" class="btn btn-danger btn-small"> -->
|
||||
<!-- <i class="fa fa-remove"></i> -->
|
||||
<!-- </a> -->
|
||||
<!-- </td> -->
|
||||
<!-- </tr> -->
|
||||
<!-- <tr ng-repeat="role in ctrl.roles" class="permissionlist__item"> -->
|
||||
<!-- <td></td> -->
|
||||
<!-- <td>{{role.name}}</td> -->
|
||||
<!-- <td><select class="gf-form-input gf-size-auto" ng-model="role.permissions" ng-options="p.value as p.text for p in ctrl.roleOptions" ng-change="ctrl.updatePermission(role)"></select></td> -->
|
||||
<!-- <td class="text-right"> -->
|
||||
<!-- -->
|
||||
<!-- </td> -->
|
||||
<!-- </tr> -->
|
||||
<!-- </tbody> -->
|
||||
<!-- </table> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
@ -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);
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ export class PlaylistSearchCtrl {
|
||||
|
||||
$timeout(() => {
|
||||
this.query.query = '';
|
||||
this.query.type = 'dash-db';
|
||||
this.searchDashboards();
|
||||
}, 100);
|
||||
}
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user