mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into docs_v5.0
This commit is contained in:
commit
49c02aa8d2
@ -18,6 +18,9 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
|
||||
|
||||
* **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
|
||||
|
||||
* **HTTP API**
|
||||
- `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url).
|
||||
|
||||
## New Dashboard Grid
|
||||
|
||||
The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution.
|
||||
|
8
Gopkg.lock
generated
8
Gopkg.lock
generated
@ -412,6 +412,12 @@
|
||||
revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
|
||||
version = "1.6.3"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/teris-io/shortid"
|
||||
packages = ["."]
|
||||
revision = "771a37caa5cf0c81f585d7b6df4dfc77e0615b5c"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/uber/jaeger-client-go"
|
||||
packages = [
|
||||
@ -625,6 +631,6 @@
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "98e8d8f5fb21fe448aeb3db41c9fed85fe3bf80400e553211cf39a9c05720e01"
|
||||
inputs-digest = "4de68f1342ba98a637ec8ca7496aeeae2021bf9e4c7c80db7924e14709151a62"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
|
@ -193,3 +193,7 @@ ignored = [
|
||||
non-go = true
|
||||
go-tests = true
|
||||
unused-packages = true
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/teris-io/shortid"
|
||||
|
@ -28,7 +28,7 @@ in that organization.
|
||||
|
||||
Can do everything scoped to the organization. For example:
|
||||
|
||||
- Add & Edit data data sources.
|
||||
- Add & Edit data sources.
|
||||
- Add & Edit organization users & teams.
|
||||
- Configure App plugins & set org settings.
|
||||
|
||||
@ -52,10 +52,12 @@ This admin flag makes a user a `Super Admin`. This means they can access the `Se
|
||||
|
||||
### Dashboard & Folder Permissions
|
||||
|
||||
> Introduced in Grafana v5.0
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
|
||||
|
||||
For dashboards and dashboard folders there is a **Permissions** page that make it possible to
|
||||
remove the default role based permissions for Editors and Viewers. It is here you can add and assign permissions to specific **Users** and **Teams**.
|
||||
remove the default role based permssions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**.
|
||||
|
||||
You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**.
|
||||
|
||||
@ -102,3 +104,4 @@ Permissions for a dashboard:
|
||||
|
||||
|
||||
Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins.
|
||||
- **View**: Can only view existing dashboars/folders.
|
||||
|
@ -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)
|
||||
|
||||
|
@ -36,7 +36,7 @@ The new dashboard layout engine allows for much easier movement and sizing of pa
|
||||
a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens
|
||||
up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video
|
||||
above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the
|
||||
new position system and look close to identical. The new panel position makes dashboards saved in v5.0 not compatible
|
||||
new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible
|
||||
with older versions of Grafana.
|
||||
|
||||
<div class="clearfix"></div>
|
||||
@ -61,7 +61,7 @@ settings views have been combined with a side nav which allows you to easily mov
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}}
|
||||
|
||||
This theme has not seen a lot of love in recent years and we felt it was time to rework it and give it a major overhaul. We are very happy with the result.
|
||||
This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result.
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@ -78,7 +78,7 @@ which is very useful if you have a lot of dashboards or multiple teams.
|
||||
|
||||
## Teams
|
||||
|
||||
A team is a new concept in Grafana v5. They are simply a group of users that can be then be used in the new permission system for dashboards and folders. Only an admin can create teams.
|
||||
A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams.
|
||||
We hope to do more with teams in future releases like integration with LDAP and a team landing page.
|
||||
|
||||
## Permissions
|
||||
@ -93,7 +93,7 @@ You can assign permissions to folders and dashboards. The default user role-base
|
||||
|
||||
In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
|
||||
But that required the service to be running before you started creating dashboards and you also needed to
|
||||
set up credentials for the HTTP API. In 5.0 we decided to improve this experience by adding a new active
|
||||
set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active
|
||||
provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can
|
||||
be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs
|
||||
and alerts as well.
|
||||
@ -105,9 +105,9 @@ It's also possible to update and delete data sources from the config file. More
|
||||
|
||||
### Dashboards
|
||||
|
||||
We also deprecated the [dashboard.json] in favor of our new dashboard provisioner that keeps dashboards on disk
|
||||
We also deprecated the `[dashboard.json]` in favor of our new dashboard provisioner that keeps dashboards on disk
|
||||
in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old
|
||||
[dashboard.json] feature. Instead of storing the dashboard in memory we now insert the dashboard into the database,
|
||||
`[dashboard.json]` feature. Instead of storing the dashboard in memory we now insert the dashboard into the database,
|
||||
which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
|
||||
expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
|
||||
|
||||
|
@ -62,7 +62,7 @@ Content-Type: application/json
|
||||
}
|
||||
"newStateDate": "2016-12-25",
|
||||
"executionError": "",
|
||||
"dashboardUri": "http://grafana.com/dashboard/db/sensors"
|
||||
"url": "http://grafana.com/dashboard/db/sensors"
|
||||
}
|
||||
]
|
||||
```
|
||||
@ -94,7 +94,7 @@ Content-Type: application/json
|
||||
"state": "alerting",
|
||||
"newStateDate": "2016-12-25",
|
||||
"executionError": "",
|
||||
"dashboardUri": "http://grafana.com/dashboard/db/sensors"
|
||||
"url": "http://grafana.com/dashboard/db/sensors"
|
||||
}
|
||||
```
|
||||
|
||||
@ -196,7 +196,7 @@ Content-Type: application/json
|
||||
|
||||
## Create alert notification
|
||||
|
||||
You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
|
||||
You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
|
||||
|
||||
`POST /api/alert-notifications`
|
||||
|
||||
@ -294,4 +294,4 @@ Content-Type: application/json
|
||||
{
|
||||
"message": "Notification deleted"
|
||||
}
|
||||
```
|
||||
```
|
||||
|
@ -90,7 +90,7 @@ Content-Type: application/json
|
||||
|
||||
## Get a single data source by Name
|
||||
|
||||
`GET /api/datasources/name/:name`
|
||||
`GET /api/datasources/:name`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
|
@ -18,12 +18,15 @@ dashboards, creating users and updating data sources.
|
||||
## Supported HTTP APIs:
|
||||
|
||||
|
||||
* [Authentication API]({{< relref "auth.md" >}})
|
||||
* [Dashboard API]({{< relref "dashboard.md" >}})
|
||||
* [Data Source API]({{< relref "data_source.md" >}})
|
||||
* [Organisation API]({{< relref "org.md" >}})
|
||||
* [User API]({{< relref "user.md" >}})
|
||||
* [Admin API]({{< relref "admin.md" >}})
|
||||
* [Snapshot API]({{< relref "snapshot.md" >}})
|
||||
* [Preferences API]({{< relref "preferences.md" >}})
|
||||
* [Other API]({{< relref "other.md" >}})
|
||||
* [Authentication API]({{< relref "/http_api/auth.md" >}})
|
||||
* [Dashboard API]({{< relref "/http_api/dashboard.md" >}})
|
||||
* [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}})
|
||||
* [Data Source API]({{< relref "http_api/data_source.md" >}})
|
||||
* [Organisation API]({{< relref "http_api/org.md" >}})
|
||||
* [Snapshot API]({{< relref "http_api/snapshot.md" >}})
|
||||
* [Annotations API]({{< relref "http_api/annotations.md" >}})
|
||||
* [Alerting API]({{< relref "http_api/alerting.md" >}})
|
||||
* [User API]({{< relref "http_api/user.md" >}})
|
||||
* [Admin API]({{< relref "http_api/admin.md" >}})
|
||||
* [Preferences API]({{< relref "http_api/preferences.md" >}})
|
||||
* [Other API]({{< relref "http_api/other.md" >}})
|
||||
|
@ -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" >}})
|
||||
|
@ -671,31 +671,6 @@ session provider you have configured.
|
||||
- **memcache:** ex: 127.0.0.1:11211
|
||||
- **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`
|
||||
|
||||
If you use MySQL or Postgres as the session store you need to create the
|
||||
session table manually.
|
||||
|
||||
Mysql Example:
|
||||
|
||||
```bash
|
||||
CREATE TABLE `session` (
|
||||
`key` CHAR(16) NOT NULL,
|
||||
`data` BLOB,
|
||||
`expiry` INT(11) UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`key`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
||||
```
|
||||
|
||||
Postgres Example:
|
||||
|
||||
```bash
|
||||
CREATE TABLE session (
|
||||
key CHAR(16) NOT NULL,
|
||||
data BYTEA,
|
||||
expiry INTEGER NOT NULL,
|
||||
PRIMARY KEY (key)
|
||||
);
|
||||
```
|
||||
|
||||
Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default).
|
||||
|
||||
### cookie_name
|
||||
|
@ -84,15 +84,15 @@ An array of:
|
||||
{
|
||||
"target":"upper_75",
|
||||
"datapoints":[
|
||||
[622,1450754160000],
|
||||
[365,1450754220000]
|
||||
[622, 1450754160000],
|
||||
[365, 1450754220000]
|
||||
]
|
||||
},
|
||||
{
|
||||
"target":"upper_90",
|
||||
"datapoints":[
|
||||
[861,1450754160000],
|
||||
[767,1450754220000]
|
||||
[861, 1450754160000],
|
||||
[767, 1450754220000]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
10
package.json
10
package.json
@ -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"
|
||||
},
|
||||
@ -114,6 +115,10 @@
|
||||
"*.scss": [
|
||||
"prettier --write",
|
||||
"git add"
|
||||
],
|
||||
"*.go": [
|
||||
"gofmt -w -s",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
@ -148,10 +153,11 @@
|
||||
"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",
|
||||
"react-transition-group": "^2.2.1",
|
||||
"remarkable": "^1.7.1",
|
||||
"rst2html": "github:thoward/rst2html#990cb89",
|
||||
"rxjs": "^5.4.3",
|
||||
|
@ -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,22 +97,40 @@ 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
|
||||
for _, alert := range alertDTOs {
|
||||
for _, dash := range dashboardsQuery.Result {
|
||||
if alert.DashboardId == dash.Id {
|
||||
alert.DashbboardUri = "db/" + dash.Slug
|
||||
alert.Url = dash.GenerateUrl()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +187,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 +281,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)
|
||||
})
|
||||
}
|
@ -15,6 +15,8 @@ func (hs *HttpServer) registerRoutes() {
|
||||
reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
|
||||
reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
|
||||
reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
|
||||
redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl()
|
||||
redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl()
|
||||
quota := middleware.Quota
|
||||
bind := binding.Bind
|
||||
|
||||
@ -63,9 +65,13 @@ func (hs *HttpServer) registerRoutes() {
|
||||
r.Get("/plugins/:id/edit", reqSignedIn, Index)
|
||||
r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
|
||||
|
||||
r.Get("/dashboard/*", reqSignedIn, Index)
|
||||
r.Get("/d/:uid/:slug", reqSignedIn, Index)
|
||||
r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index)
|
||||
r.Get("/dashboard/script/*", reqSignedIn, Index)
|
||||
r.Get("/dashboard-solo/snapshot/*", Index)
|
||||
r.Get("/dashboard-solo/*", reqSignedIn, Index)
|
||||
r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
|
||||
r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloUrl, Index)
|
||||
r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
|
||||
r.Get("/import/dashboard", reqSignedIn, Index)
|
||||
r.Get("/dashboards/", reqSignedIn, Index)
|
||||
r.Get("/dashboards/*", reqSignedIn, Index)
|
||||
@ -144,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) {
|
||||
@ -242,20 +248,25 @@ func (hs *HttpServer) registerRoutes() {
|
||||
|
||||
// Dashboard
|
||||
apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
|
||||
dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
|
||||
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))
|
||||
@ -317,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,20 +38,19 @@ 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 {
|
||||
dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
|
||||
dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid"))
|
||||
if rsp != nil {
|
||||
return rsp
|
||||
}
|
||||
|
||||
guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
|
||||
if canView, err := guardian.CanView(); err != nil || !canView {
|
||||
fmt.Printf("%v", err)
|
||||
return dashboardGuardianResponse(err)
|
||||
}
|
||||
|
||||
@ -89,7 +88,8 @@ func GetDashboard(c *middleware.Context) Response {
|
||||
HasAcl: dash.HasAcl,
|
||||
IsFolder: dash.IsFolder,
|
||||
FolderId: dash.FolderId,
|
||||
FolderTitle: "Root",
|
||||
Url: dash.GetUrl(),
|
||||
FolderTitle: "General",
|
||||
}
|
||||
|
||||
// lookup folder title
|
||||
@ -99,6 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
|
||||
return ApiError(500, "Dashboard folder could not be read", err)
|
||||
}
|
||||
meta.FolderTitle = query.Result.Title
|
||||
meta.FolderUrl = query.Result.GetUrl()
|
||||
}
|
||||
|
||||
// make sure db version is in sync with json model version
|
||||
@ -124,8 +125,15 @@ func getUserLogin(userId int64) string {
|
||||
}
|
||||
}
|
||||
|
||||
func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) {
|
||||
query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
|
||||
func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dashboard, Response) {
|
||||
var query m.GetDashboardQuery
|
||||
|
||||
if len(uid) > 0 {
|
||||
query = m.GetDashboardQuery{Uid: uid, Id: id, OrgId: orgId}
|
||||
} else {
|
||||
query = m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return nil, ApiError(404, "Dashboard not found", err)
|
||||
}
|
||||
@ -133,7 +141,37 @@ func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Respo
|
||||
}
|
||||
|
||||
func DeleteDashboard(c *middleware.Context) Response {
|
||||
dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
|
||||
query := m.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return ApiError(500, "Failed to retrieve dashboards by slug", err)
|
||||
}
|
||||
|
||||
if len(query.Result) > 1 {
|
||||
return Json(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()})
|
||||
}
|
||||
|
||||
dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, "")
|
||||
if rsp != nil {
|
||||
return rsp
|
||||
}
|
||||
|
||||
guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
|
||||
if canSave, err := guardian.CanSave(); err != nil || !canSave {
|
||||
return dashboardGuardianResponse(err)
|
||||
}
|
||||
|
||||
cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id}
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
return ApiError(500, "Failed to delete dashboard", err)
|
||||
}
|
||||
|
||||
var resp = map[string]interface{}{"title": dash.Title}
|
||||
return Json(200, resp)
|
||||
}
|
||||
|
||||
func DeleteDashboardByUid(c *middleware.Context) Response {
|
||||
dash, rsp := getDashboardHelper(c.OrgId, "", 0, c.Params(":uid"))
|
||||
if rsp != nil {
|
||||
return rsp
|
||||
}
|
||||
@ -158,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)
|
||||
}
|
||||
@ -201,7 +246,10 @@ 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.ErrDashboardWithSameNameInFolderExists {
|
||||
return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
|
||||
}
|
||||
if err == m.ErrDashboardVersionMismatch {
|
||||
@ -225,8 +273,17 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
|
||||
return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
|
||||
}
|
||||
|
||||
dashboard.IsFolder = dash.IsFolder
|
||||
|
||||
c.TimeRequest(metrics.M_Api_Dashboard_Save)
|
||||
return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id})
|
||||
return Json(200, util.DynMap{
|
||||
"status": "success",
|
||||
"slug": dashboard.Slug,
|
||||
"version": dashboard.Version,
|
||||
"id": dashboard.Id,
|
||||
"uid": dashboard.Uid,
|
||||
"url": dashboard.GetUrl(),
|
||||
})
|
||||
}
|
||||
|
||||
func GetHomeDashboard(c *middleware.Context) Response {
|
||||
@ -236,10 +293,11 @@ func GetHomeDashboard(c *middleware.Context) Response {
|
||||
}
|
||||
|
||||
if prefsQuery.Result.HomeDashboardId != 0 {
|
||||
slugQuery := m.GetDashboardSlugByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
|
||||
slugQuery := m.GetDashboardRefByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
|
||||
err := bus.Dispatch(&slugQuery)
|
||||
if err == nil {
|
||||
dashRedirect := dtos.DashboardRedirect{RedirectUri: "db/" + slugQuery.Result}
|
||||
url := m.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug)
|
||||
dashRedirect := dtos.DashboardRedirect{RedirectUri: url}
|
||||
return Json(200, &dashRedirect)
|
||||
} else {
|
||||
log.Warn("Failed to get slug from database, %s", err.Error())
|
||||
@ -255,7 +313,7 @@ func GetHomeDashboard(c *middleware.Context) Response {
|
||||
dash := dtos.DashboardFullWithMeta{}
|
||||
dash.Meta.IsHome = true
|
||||
dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR)
|
||||
dash.Meta.FolderTitle = "Root"
|
||||
dash.Meta.FolderTitle = "General"
|
||||
|
||||
jsonParser := json.NewDecoder(file)
|
||||
if err := jsonParser.Decode(&dash.Dashboard); err != nil {
|
||||
@ -393,7 +451,7 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff
|
||||
|
||||
// RestoreDashboardVersion restores a dashboard to the given version.
|
||||
func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
|
||||
dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"))
|
||||
dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"), "")
|
||||
if rsp != nil {
|
||||
return rsp
|
||||
}
|
||||
@ -416,6 +474,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard
|
||||
saveCmd.UserId = c.UserId
|
||||
saveCmd.Dashboard = version.Data
|
||||
saveCmd.Dashboard.Set("version", dash.Version)
|
||||
saveCmd.Dashboard.Set("uid", dash.Uid)
|
||||
saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
|
||||
|
||||
return PostDashboard(c, saveCmd)
|
||||
@ -431,3 +490,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)
|
||||
}
|
||||
|
@ -24,6 +24,12 @@ func GetDashboardAclList(c *middleware.Context) Response {
|
||||
return ApiError(500, "Failed to get dashboard acl", err)
|
||||
}
|
||||
|
||||
for _, perm := range acl {
|
||||
if perm.Slug != "" {
|
||||
perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
return Json(200, acl)
|
||||
}
|
||||
|
||||
@ -51,6 +57,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 +84,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")
|
||||
@ -39,8 +39,17 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
fakeDash.FolderId = 1
|
||||
fakeDash.HasAcl = false
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
|
||||
dashboards := []*m.Dashboard{fakeDash}
|
||||
query.Result = dashboards
|
||||
return nil
|
||||
})
|
||||
|
||||
var getDashboardQueries []*m.GetDashboardQuery
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
getDashboardQueries = append(getDashboardQueries, query)
|
||||
return nil
|
||||
})
|
||||
|
||||
@ -70,12 +79,20 @@ 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
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should not be able to edit or save dashboard", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeFalse)
|
||||
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||
@ -83,9 +100,36 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should not be able to edit or save dashboard", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeFalse)
|
||||
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||
So(dash.Meta.CanAdmin, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -107,9 +151,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
Convey("When user is an Org Editor", func() {
|
||||
role := m.ROLE_EDITOR
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should be able to edit or save dashboard", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||
@ -117,9 +165,36 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should be able to edit or save dashboard", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||
So(dash.Meta.CanAdmin, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -133,8 +208,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
CallPostDashboardShouldReturnSuccess(sc)
|
||||
})
|
||||
|
||||
Convey("When saving a dashboard folder in another folder", func() {
|
||||
@ -168,6 +242,12 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
fakeDash.HasAcl = true
|
||||
setting.ViewersCanEdit = false
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
|
||||
dashboards := []*m.Dashboard{fakeDash}
|
||||
query.Result = dashboards
|
||||
return nil
|
||||
})
|
||||
|
||||
aclMockResp := []*m.DashboardAclInfoDTO{
|
||||
{
|
||||
DashboardId: 1,
|
||||
@ -181,8 +261,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
var getDashboardQueries []*m.GetDashboardQuery
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
getDashboardQueries = append(getDashboardQueries, query)
|
||||
return nil
|
||||
})
|
||||
|
||||
@ -200,21 +283,59 @@ 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
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = GetDashboard
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should be denied access", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = GetDashboard
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should be denied access", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -236,18 +357,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
|
||||
role := m.ROLE_EDITOR
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = GetDashboard
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should be denied access", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = GetDashboard
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should be denied access", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -278,9 +429,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard with edit rights", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||
@ -288,9 +443,36 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard with edit rights", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||
So(dash.Meta.CanAdmin, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -304,8 +486,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
CallPostDashboardShouldReturnSuccess(sc)
|
||||
})
|
||||
})
|
||||
|
||||
@ -322,9 +503,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||
@ -332,9 +517,36 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||
So(dash.Meta.CanAdmin, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -350,9 +562,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard with edit rights", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||
@ -360,9 +576,36 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard with edit rights", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||
So(dash.Meta.CanAdmin, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -376,8 +619,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
CallPostDashboardShouldReturnSuccess(sc)
|
||||
})
|
||||
})
|
||||
|
||||
@ -393,18 +635,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
|
||||
Convey("Should not be able to edit or save dashboard", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeFalse)
|
||||
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
dash := GetDashboardShouldReturn200(sc)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
|
||||
Convey("Should not be able to edit or save dashboard", func() {
|
||||
So(dash.Meta.CanEdit, ShouldBeFalse)
|
||||
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by slug", func() {
|
||||
So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboardByUid(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
|
||||
Convey("Should lookup dashboard by uid", func() {
|
||||
So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
|
||||
})
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
|
||||
@ -423,6 +695,37 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given two dashboards with the same title in different folders", t, func() {
|
||||
dashOne := m.NewDashboard("dash")
|
||||
dashOne.Id = 2
|
||||
dashOne.FolderId = 1
|
||||
dashOne.HasAcl = false
|
||||
|
||||
dashTwo := m.NewDashboard("dash")
|
||||
dashTwo.Id = 4
|
||||
dashTwo.FolderId = 3
|
||||
dashTwo.HasAcl = false
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
|
||||
dashboards := []*m.Dashboard{dashOne, dashTwo}
|
||||
query.Result = dashboards
|
||||
return nil
|
||||
})
|
||||
|
||||
role := m.ROLE_EDITOR
|
||||
|
||||
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
|
||||
CallDeleteDashboard(sc)
|
||||
|
||||
Convey("Should result in 412 Precondition failed", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 412)
|
||||
result := sc.ToJson()
|
||||
So(result.Get("status").MustString(), ShouldEqual, "multiple-slugs-exists")
|
||||
So(result.Get("message").MustString(), ShouldEqual, m.ErrDashboardsWithSameSlugExists.Error())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
|
||||
@ -467,6 +770,15 @@ func CallDeleteDashboard(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func CallDeleteDashboardByUid(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.handlerFunc = DeleteDashboardByUid
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func CallPostDashboard(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
@ -484,24 +796,23 @@ func CallPostDashboard(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
result := sc.ToJson()
|
||||
So(result.Get("status").MustString(), ShouldEqual, "success")
|
||||
So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
|
||||
So(result.Get("uid").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("slug").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("url").MustString(), ShouldNotBeNil)
|
||||
}
|
||||
|
||||
func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, 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 := setupScenarioContext(url)
|
||||
sc.defaultHandler = wrap(func(c *middleware.Context) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = TestUserID
|
||||
@ -519,3 +830,10 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) ToJson() *simplejson.Json {
|
||||
var result *simplejson.Json
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&result)
|
||||
So(err, ShouldBeNil)
|
||||
return result
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -19,7 +19,8 @@ type AlertRule struct {
|
||||
EvalDate time.Time `json:"evalDate"`
|
||||
EvalData *simplejson.Json `json:"evalData"`
|
||||
ExecutionError string `json:"executionError"`
|
||||
DashbboardUri string `json:"dashboardUri"`
|
||||
Url string `json:"url"`
|
||||
CanEdit bool `json:"canEdit"`
|
||||
}
|
||||
|
||||
type AlertNotification struct {
|
||||
|
@ -16,6 +16,7 @@ type DashboardMeta struct {
|
||||
CanAdmin bool `json:"canAdmin"`
|
||||
CanStar bool `json:"canStar"`
|
||||
Slug string `json:"slug"`
|
||||
Url string `json:"url"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
@ -26,6 +27,7 @@ type DashboardMeta struct {
|
||||
IsFolder bool `json:"isFolder"`
|
||||
FolderId int64 `json:"folderId"`
|
||||
FolderTitle string `json:"folderTitle"`
|
||||
FolderUrl string `json:"folderUrl"`
|
||||
}
|
||||
|
||||
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",
|
||||
|
@ -91,9 +91,15 @@ func RenderToPng(params *RenderOpts) (string, error) {
|
||||
timeout = 15
|
||||
}
|
||||
|
||||
phantomDebugArg := "--debug=false"
|
||||
if log.GetLogLevelFor("png-renderer") >= log.LvlDebug {
|
||||
phantomDebugArg = "--debug=true"
|
||||
}
|
||||
|
||||
cmdArgs := []string{
|
||||
"--ignore-ssl-errors=true",
|
||||
"--web-security=false",
|
||||
phantomDebugArg,
|
||||
scriptPath,
|
||||
"url=" + url,
|
||||
"width=" + params.Width,
|
||||
@ -109,15 +115,13 @@ func RenderToPng(params *RenderOpts) (string, error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
output, err := cmd.StdoutPipe()
|
||||
|
||||
if err != nil {
|
||||
rendererLog.Error("Could not acquire stdout pipe", err)
|
||||
return "", err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
|
||||
if params.Timezone != "" {
|
||||
baseEnviron := os.Environ()
|
||||
@ -126,11 +130,12 @@ func RenderToPng(params *RenderOpts) (string, error) {
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
rendererLog.Error("Could not start command", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
go io.Copy(os.Stdout, stdout)
|
||||
go io.Copy(os.Stdout, stderr)
|
||||
logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ")
|
||||
go io.Copy(logWriter, output)
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
|
||||
var Root log15.Logger
|
||||
var loggersToClose []DisposableHandler
|
||||
var filters map[string]log15.Lvl
|
||||
|
||||
func init() {
|
||||
loggersToClose = make([]DisposableHandler, 0)
|
||||
@ -114,6 +115,25 @@ func Close() {
|
||||
loggersToClose = make([]DisposableHandler, 0)
|
||||
}
|
||||
|
||||
func GetLogLevelFor(name string) Lvl {
|
||||
if level, ok := filters[name]; ok {
|
||||
switch level {
|
||||
case log15.LvlWarn:
|
||||
return LvlWarn
|
||||
case log15.LvlInfo:
|
||||
return LvlInfo
|
||||
case log15.LvlError:
|
||||
return LvlError
|
||||
case log15.LvlCrit:
|
||||
return LvlCrit
|
||||
default:
|
||||
return LvlDebug
|
||||
}
|
||||
}
|
||||
|
||||
return LvlInfo
|
||||
}
|
||||
|
||||
var logLevels = map[string]log15.Lvl{
|
||||
"trace": log15.LvlDebug,
|
||||
"debug": log15.LvlDebug,
|
||||
@ -187,7 +207,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
|
||||
|
||||
// Log level.
|
||||
_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
|
||||
modeFilters := getFilters(util.SplitString(sec.Key("filters").String()))
|
||||
filters := getFilters(util.SplitString(sec.Key("filters").String()))
|
||||
format := getLogFormat(sec.Key("format").MustString(""))
|
||||
|
||||
var handler log15.Handler
|
||||
@ -219,12 +239,12 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
|
||||
}
|
||||
|
||||
for key, value := range defaultFilters {
|
||||
if _, exist := modeFilters[key]; !exist {
|
||||
modeFilters[key] = value
|
||||
if _, exist := filters[key]; !exist {
|
||||
filters[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
handler = LogFilterHandler(level, modeFilters, handler)
|
||||
handler = LogFilterHandler(level, filters, handler)
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
|
||||
|
39
pkg/log/log_writer.go
Normal file
39
pkg/log/log_writer.go
Normal file
@ -0,0 +1,39 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type logWriterImpl struct {
|
||||
log Logger
|
||||
level Lvl
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewLogWriter(log Logger, level Lvl, prefix string) io.Writer {
|
||||
return &logWriterImpl{
|
||||
log: log,
|
||||
level: level,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logWriterImpl) Write(p []byte) (n int, err error) {
|
||||
message := l.prefix + strings.TrimSpace(string(p))
|
||||
|
||||
switch l.level {
|
||||
case LvlCrit:
|
||||
l.log.Crit(message)
|
||||
case LvlError:
|
||||
l.log.Error(message)
|
||||
case LvlWarn:
|
||||
l.log.Warn(message)
|
||||
case LvlInfo:
|
||||
l.log.Info(message)
|
||||
default:
|
||||
l.log.Debug(message)
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
116
pkg/log/log_writer_test.go
Normal file
116
pkg/log/log_writer_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inconshreveable/log15"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
type FakeLogger struct {
|
||||
debug string
|
||||
info string
|
||||
warn string
|
||||
err string
|
||||
crit string
|
||||
}
|
||||
|
||||
func (f *FakeLogger) New(ctx ...interface{}) log15.Logger {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakeLogger) Debug(msg string, ctx ...interface{}) {
|
||||
f.debug = msg
|
||||
}
|
||||
|
||||
func (f *FakeLogger) Info(msg string, ctx ...interface{}) {
|
||||
f.info = msg
|
||||
}
|
||||
|
||||
func (f *FakeLogger) Warn(msg string, ctx ...interface{}) {
|
||||
f.warn = msg
|
||||
}
|
||||
|
||||
func (f *FakeLogger) Error(msg string, ctx ...interface{}) {
|
||||
f.err = msg
|
||||
}
|
||||
|
||||
func (f *FakeLogger) Crit(msg string, ctx ...interface{}) {
|
||||
f.crit = msg
|
||||
}
|
||||
|
||||
func (f *FakeLogger) GetHandler() log15.Handler {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakeLogger) SetHandler(l log15.Handler) {}
|
||||
|
||||
func TestLogWriter(t *testing.T) {
|
||||
Convey("When writing to a LogWriter", t, func() {
|
||||
Convey("Should write using the correct level [crit]", func() {
|
||||
fake := &FakeLogger{}
|
||||
|
||||
crit := NewLogWriter(fake, LvlCrit, "")
|
||||
n, err := crit.Write([]byte("crit"))
|
||||
|
||||
So(n, ShouldEqual, 4)
|
||||
So(err, ShouldBeNil)
|
||||
So(fake.crit, ShouldEqual, "crit")
|
||||
})
|
||||
|
||||
Convey("Should write using the correct level [error]", func() {
|
||||
fake := &FakeLogger{}
|
||||
|
||||
crit := NewLogWriter(fake, LvlError, "")
|
||||
n, err := crit.Write([]byte("error"))
|
||||
|
||||
So(n, ShouldEqual, 5)
|
||||
So(err, ShouldBeNil)
|
||||
So(fake.err, ShouldEqual, "error")
|
||||
})
|
||||
|
||||
Convey("Should write using the correct level [warn]", func() {
|
||||
fake := &FakeLogger{}
|
||||
|
||||
crit := NewLogWriter(fake, LvlWarn, "")
|
||||
n, err := crit.Write([]byte("warn"))
|
||||
|
||||
So(n, ShouldEqual, 4)
|
||||
So(err, ShouldBeNil)
|
||||
So(fake.warn, ShouldEqual, "warn")
|
||||
})
|
||||
|
||||
Convey("Should write using the correct level [info]", func() {
|
||||
fake := &FakeLogger{}
|
||||
|
||||
crit := NewLogWriter(fake, LvlInfo, "")
|
||||
n, err := crit.Write([]byte("info"))
|
||||
|
||||
So(n, ShouldEqual, 4)
|
||||
So(err, ShouldBeNil)
|
||||
So(fake.info, ShouldEqual, "info")
|
||||
})
|
||||
|
||||
Convey("Should write using the correct level [debug]", func() {
|
||||
fake := &FakeLogger{}
|
||||
|
||||
crit := NewLogWriter(fake, LvlDebug, "")
|
||||
n, err := crit.Write([]byte("debug"))
|
||||
|
||||
So(n, ShouldEqual, 5)
|
||||
So(err, ShouldBeNil)
|
||||
So(fake.debug, ShouldEqual, "debug")
|
||||
})
|
||||
|
||||
Convey("Should prefix the output with the prefix", func() {
|
||||
fake := &FakeLogger{}
|
||||
|
||||
crit := NewLogWriter(fake, LvlDebug, "prefix")
|
||||
n, err := crit.Write([]byte("debug"))
|
||||
|
||||
So(n, ShouldEqual, 5) // n is how much of input consumed
|
||||
So(err, ShouldBeNil)
|
||||
So(fake.debug, ShouldEqual, "prefixdebug")
|
||||
})
|
||||
})
|
||||
}
|
49
pkg/middleware/dashboard_redirect.go
Normal file
49
pkg/middleware/dashboard_redirect.go
Normal file
@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
func getDashboardUrlBySlug(orgId int64, slug string) (string, error) {
|
||||
query := m.GetDashboardQuery{Slug: slug, OrgId: orgId}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return "", m.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
return m.GetDashboardUrl(query.Result.Uid, query.Result.Slug), nil
|
||||
}
|
||||
|
||||
func RedirectFromLegacyDashboardUrl() macaron.Handler {
|
||||
return func(c *Context) {
|
||||
slug := c.Params("slug")
|
||||
|
||||
if slug != "" {
|
||||
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
|
||||
url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
|
||||
c.Redirect(url, 301)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RedirectFromLegacyDashboardSoloUrl() macaron.Handler {
|
||||
return func(c *Context) {
|
||||
slug := c.Params("slug")
|
||||
|
||||
if slug != "" {
|
||||
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
|
||||
url = strings.Replace(url, "/d/", "/d-solo/", 1)
|
||||
url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
|
||||
c.Redirect(url, 301)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
58
pkg/middleware/dashboard_redirect_test.go
Normal file
58
pkg/middleware/dashboard_redirect_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestMiddlewareDashboardRedirect(t *testing.T) {
|
||||
Convey("Given the dashboard redirect middleware", t, func() {
|
||||
bus.ClearBusHandlers()
|
||||
redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardUrl()
|
||||
redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloUrl()
|
||||
|
||||
fakeDash := m.NewDashboard("Child dash")
|
||||
fakeDash.Id = 1
|
||||
fakeDash.FolderId = 1
|
||||
fakeDash.HasAcl = false
|
||||
fakeDash.Uid = util.GenerateShortUid()
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) {
|
||||
sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
|
||||
|
||||
sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
redirectUrl, _ := sc.resp.Result().Location()
|
||||
So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug))
|
||||
So(len(redirectUrl.Query()), ShouldEqual, 2)
|
||||
})
|
||||
})
|
||||
|
||||
middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) {
|
||||
sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
|
||||
|
||||
sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
redirectUrl, _ := sc.resp.Result().Location()
|
||||
expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
|
||||
expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1)
|
||||
So(redirectUrl.Path, ShouldEqual, expectedUrl)
|
||||
So(len(redirectUrl.Query()), ShouldEqual, 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
@ -399,6 +399,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
|
||||
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
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
|
||||
sc.handlerFunc = fn
|
||||
return sc
|
||||
|
@ -159,10 +159,6 @@ type SetAlertStateCommand struct {
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type DeleteAlertCommand struct {
|
||||
AlertId int64
|
||||
}
|
||||
|
||||
//Queries
|
||||
type GetAlertsQuery struct {
|
||||
OrgId int64
|
||||
|
@ -59,6 +59,11 @@ type DashboardAclInfoDTO struct {
|
||||
Role *RoleType `json:"role,omitempty"`
|
||||
Permission PermissionType `json:"permission"`
|
||||
PermissionName string `json:"permissionName"`
|
||||
Uid string `json:"uid"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
IsFolder bool `json:"isFolder"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -2,23 +2,28 @@ package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gosimple/slug"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// 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")
|
||||
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 {
|
||||
@ -39,6 +44,7 @@ var (
|
||||
// Dashboard model
|
||||
type Dashboard struct {
|
||||
Id int64
|
||||
Uid string
|
||||
Slug string
|
||||
OrgId int64
|
||||
GnetId int64
|
||||
@ -107,6 +113,10 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
|
||||
dash.GnetId = int64(gnetId)
|
||||
}
|
||||
|
||||
if uid, err := dash.Data.Get("uid").String(); err == nil {
|
||||
dash.Uid = uid
|
||||
}
|
||||
|
||||
return dash
|
||||
}
|
||||
|
||||
@ -147,6 +157,40 @@ 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)
|
||||
}
|
||||
|
||||
// Return the html url for a dashboard
|
||||
func (dash *Dashboard) GenerateUrl() string {
|
||||
return GetDashboardUrl(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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Return the full url for a dashboard
|
||||
func GetFullDashboardUrl(uid string, slug string) string {
|
||||
return fmt.Sprintf("%s%s", setting.AppUrl, GetDashboardUrl(uid, slug))
|
||||
}
|
||||
|
||||
// GetFolderUrl return the html url for a folder
|
||||
func GetFolderUrl(folderUid string, slug string) string {
|
||||
return fmt.Sprintf("%s/dashboards/f/%s/%s", setting.AppSubUrl, folderUid, slug)
|
||||
}
|
||||
|
||||
//
|
||||
// COMMANDS
|
||||
//
|
||||
@ -177,8 +221,9 @@ type DeleteDashboardCommand struct {
|
||||
//
|
||||
|
||||
type GetDashboardQuery struct {
|
||||
Slug string // required if no Id is specified
|
||||
Slug string // required if no Id or Uid is specified
|
||||
Id int64 // optional if slug is set
|
||||
Uid string // optional if slug is set
|
||||
OrgId int64
|
||||
|
||||
Result *Dashboard
|
||||
@ -199,6 +244,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
|
||||
@ -209,3 +262,38 @@ type GetDashboardSlugByIdQuery struct {
|
||||
Id int64
|
||||
Result string
|
||||
}
|
||||
|
||||
type GetDashboardsBySlugQuery struct {
|
||||
OrgId int64
|
||||
Slug string
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type DashboardRef struct {
|
||||
Uid string
|
||||
Slug string
|
||||
}
|
||||
|
||||
type GetDashboardRefByIdQuery struct {
|
||||
Id int64
|
||||
Result *DashboardRef
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -12,17 +12,19 @@ import (
|
||||
)
|
||||
|
||||
type EvalContext struct {
|
||||
Firing bool
|
||||
IsTestRun bool
|
||||
EvalMatches []*EvalMatch
|
||||
Logs []*ResultLogEntry
|
||||
Error error
|
||||
ConditionEvals string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Rule *Rule
|
||||
log log.Logger
|
||||
dashboardSlug string
|
||||
Firing bool
|
||||
IsTestRun bool
|
||||
EvalMatches []*EvalMatch
|
||||
Logs []*ResultLogEntry
|
||||
Error error
|
||||
ConditionEvals string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Rule *Rule
|
||||
log log.Logger
|
||||
|
||||
dashboardRef *m.DashboardRef
|
||||
|
||||
ImagePublicUrl string
|
||||
ImageOnDiskPath string
|
||||
NoDataFound bool
|
||||
@ -83,29 +85,30 @@ func (c *EvalContext) GetNotificationTitle() string {
|
||||
return "[" + c.GetStateModel().Text + "] " + c.Rule.Name
|
||||
}
|
||||
|
||||
func (c *EvalContext) GetDashboardSlug() (string, error) {
|
||||
if c.dashboardSlug != "" {
|
||||
return c.dashboardSlug, nil
|
||||
func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) {
|
||||
if c.dashboardRef != nil {
|
||||
return c.dashboardRef, nil
|
||||
}
|
||||
|
||||
slugQuery := &m.GetDashboardSlugByIdQuery{Id: c.Rule.DashboardId}
|
||||
if err := bus.Dispatch(slugQuery); err != nil {
|
||||
return "", err
|
||||
uidQuery := &m.GetDashboardRefByIdQuery{Id: c.Rule.DashboardId}
|
||||
if err := bus.Dispatch(uidQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.dashboardSlug = slugQuery.Result
|
||||
return c.dashboardSlug, nil
|
||||
c.dashboardRef = uidQuery.Result
|
||||
return c.dashboardRef, nil
|
||||
}
|
||||
|
||||
const urlFormat = "%s?fullscreen=true&edit=true&tab=alert&panelId=%d&orgId=%d"
|
||||
|
||||
func (c *EvalContext) GetRuleUrl() (string, error) {
|
||||
if c.IsTestRun {
|
||||
return setting.AppUrl, nil
|
||||
}
|
||||
|
||||
if slug, err := c.GetDashboardSlug(); err != nil {
|
||||
if ref, err := c.GetDashboardUID(); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
ruleUrl := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d&orgId=%d", setting.AppUrl, slug, c.Rule.PanelId, c.Rule.OrgId)
|
||||
return ruleUrl, nil
|
||||
return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil
|
||||
}
|
||||
}
|
||||
|
@ -87,10 +87,10 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
|
||||
IsAlertContext: true,
|
||||
}
|
||||
|
||||
if slug, err := context.GetDashboardSlug(); err != nil {
|
||||
if ref, err := context.GetDashboardUID(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
renderOpts.Path = fmt.Sprintf("dashboard-solo/db/%s?&panelId=%d", slug, context.Rule.PanelId)
|
||||
renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?panelId=%d", ref.Uid, ref.Slug, context.Rule.PanelId)
|
||||
}
|
||||
|
||||
if imagePath, err := renderer.RenderToPng(renderOpts); err != nil {
|
||||
|
@ -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
|
||||
|
@ -13,15 +13,17 @@ const (
|
||||
|
||||
type Hit struct {
|
||||
Id int64 `json:"id"`
|
||||
Uid string `json:"uid"`
|
||||
Title string `json:"title"`
|
||||
Uri string `json:"uri"`
|
||||
Slug string `json:"slug"`
|
||||
Url string `json:"url"`
|
||||
Type HitType `json:"type"`
|
||||
Tags []string `json:"tags"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
FolderId int64 `json:"folderId,omitempty"`
|
||||
FolderUid string `json:"folderUid,omitempty"`
|
||||
FolderTitle string `json:"folderTitle,omitempty"`
|
||||
FolderSlug string `json:"folderSlug,omitempty"`
|
||||
FolderUrl string `json:"folderUrl,omitempty"`
|
||||
}
|
||||
|
||||
type HitList []*Hit
|
||||
|
@ -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() {
|
||||
@ -17,17 +19,23 @@ func init() {
|
||||
bus.AddHandler("sql", SearchDashboards)
|
||||
bus.AddHandler("sql", GetDashboardTags)
|
||||
bus.AddHandler("sql", GetDashboardSlugById)
|
||||
bus.AddHandler("sql", GetDashboardUIDById)
|
||||
bus.AddHandler("sql", GetDashboardsByPluginId)
|
||||
bus.AddHandler("sql", GetFoldersForSignedInUser)
|
||||
bus.AddHandler("sql", GetDashboardPermissionsForUser)
|
||||
bus.AddHandler("sql", GetDashboardsBySlug)
|
||||
}
|
||||
|
||||
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 +57,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 +112,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 {
|
||||
@ -139,6 +162,40 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
if dash.FolderId > 0 {
|
||||
@ -165,7 +222,7 @@ func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
|
||||
}
|
||||
|
||||
func GetDashboard(query *m.GetDashboardQuery) error {
|
||||
dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id}
|
||||
dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid}
|
||||
has, err := x.Get(&dashboard)
|
||||
|
||||
if err != nil {
|
||||
@ -175,17 +232,20 @@ func GetDashboard(query *m.GetDashboardQuery) error {
|
||||
}
|
||||
|
||||
dashboard.Data.Set("id", dashboard.Id)
|
||||
dashboard.Data.Set("uid", dashboard.Uid)
|
||||
query.Result = &dashboard
|
||||
return nil
|
||||
}
|
||||
|
||||
type DashboardSearchProjection struct {
|
||||
Id int64
|
||||
Uid string
|
||||
Title string
|
||||
Slug string
|
||||
Term string
|
||||
IsFolder bool
|
||||
FolderId int64
|
||||
FolderUid string
|
||||
FolderSlug string
|
||||
FolderTitle string
|
||||
}
|
||||
@ -258,15 +318,21 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
|
||||
if !exists {
|
||||
hit = &search.Hit{
|
||||
Id: item.Id,
|
||||
Uid: item.Uid,
|
||||
Title: item.Title,
|
||||
Uri: "db/" + item.Slug,
|
||||
Slug: item.Slug,
|
||||
Url: m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
|
||||
Type: getHitType(item),
|
||||
FolderId: item.FolderId,
|
||||
FolderUid: item.FolderUid,
|
||||
FolderTitle: item.FolderTitle,
|
||||
FolderSlug: item.FolderSlug,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
if item.FolderId > 0 {
|
||||
hit.FolderUrl = m.GetFolderUrl(item.FolderUid, item.FolderSlug)
|
||||
}
|
||||
|
||||
query.Result = append(query.Result, hit)
|
||||
hits[item.Id] = hit
|
||||
}
|
||||
@ -291,6 +357,54 @@ 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 = ? AND
|
||||
(
|
||||
(d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
|
||||
OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
|
||||
)`
|
||||
params = append(params, query.OrgId)
|
||||
params = append(params, dialect.BooleanStr(true))
|
||||
params = append(params, dialect.BooleanStr(true))
|
||||
params = append(params, query.SignedInUser.UserId)
|
||||
params = append(params, query.SignedInUser.UserId)
|
||||
params = append(params, dialect.BooleanStr(false))
|
||||
|
||||
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}
|
||||
@ -343,6 +457,78 @@ 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, query.OrgId)
|
||||
params = append(params, dialect.BooleanStr(true))
|
||||
params = append(params, query.UserId)
|
||||
params = append(params, query.UserId)
|
||||
params = append(params, dialect.BooleanStr(false))
|
||||
|
||||
x.ShowSQL(true)
|
||||
err := x.Sql(sql, params...).Find(&query.Result)
|
||||
x.ShowSQL(false)
|
||||
|
||||
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)
|
||||
@ -365,7 +551,7 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
|
||||
var rawSql = `SELECT slug from dashboard WHERE Id=?`
|
||||
var slug = DashboardSlugDTO{}
|
||||
|
||||
exists, err := x.Sql(rawSql, query.Id).Get(&slug)
|
||||
exists, err := x.SQL(rawSql, query.Id).Get(&slug)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@ -376,3 +562,31 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
|
||||
query.Result = slug.Slug
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDashboardsBySlug(query *m.GetDashboardsBySlugQuery) error {
|
||||
var dashboards []*m.Dashboard
|
||||
|
||||
if err := x.Where("org_id=? AND slug=?", query.OrgId, query.Slug).Find(&dashboards); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query.Result = dashboards
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error {
|
||||
var rawSql = `SELECT uid, slug from dashboard WHERE Id=?`
|
||||
|
||||
us := &m.DashboardRef{}
|
||||
|
||||
exists, err := x.SQL(rawSql, query.Id).Get(us)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if exists == false {
|
||||
return m.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
query.Result = us
|
||||
return nil
|
||||
}
|
||||
|
@ -113,6 +113,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveDashboardAcl removes a specified permission from the dashboard acl
|
||||
func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?"
|
||||
@ -125,15 +126,16 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three
|
||||
// different places.
|
||||
// 1) Permissions for the dashboard
|
||||
// 2) permissions for its parent folder
|
||||
// 3) if no specific permissions have been set for the dashboard or its parent folder then get the default permissions
|
||||
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 +145,85 @@ 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,
|
||||
'' as title,
|
||||
'' as slug,
|
||||
'' as uid,` +
|
||||
dialect.BooleanStr(false) + ` AS is_folder
|
||||
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 := `
|
||||
-- get permissions for the dashboard and its parent folder
|
||||
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,
|
||||
d.title,
|
||||
d.slug,
|
||||
d.uid,
|
||||
d.is_folder
|
||||
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
|
||||
LEFT OUTER JOIN dashboard d on da.dashboard_id = d.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 permissions if folder or dashboard field "has_acl" is false
|
||||
|
||||
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,
|
||||
folder.title,
|
||||
folder.slug,
|
||||
folder.uid,
|
||||
folder.is_folder
|
||||
FROM dashboard_acl as da,
|
||||
dashboard as dash
|
||||
LEFT OUTER 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
@ -1,15 +1,16 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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) {
|
||||
@ -30,15 +31,33 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
So(savedDash.Id, ShouldNotEqual, 0)
|
||||
So(savedDash.IsFolder, ShouldBeFalse)
|
||||
So(savedDash.FolderId, ShouldBeGreaterThan, 0)
|
||||
So(len(savedDash.Uid), ShouldBeGreaterThan, 0)
|
||||
|
||||
So(savedFolder.Title, ShouldEqual, "1 test dash folder")
|
||||
So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
|
||||
So(savedFolder.Id, ShouldNotEqual, 0)
|
||||
So(savedFolder.IsFolder, ShouldBeTrue)
|
||||
So(savedFolder.FolderId, ShouldEqual, 0)
|
||||
So(len(savedFolder.Uid), ShouldBeGreaterThan, 0)
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard", func() {
|
||||
Convey("Should be able to get dashboard by id", func() {
|
||||
query := m.GetDashboardQuery{
|
||||
Id: savedDash.Id,
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
err := GetDashboard(&query)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(query.Result.Title, ShouldEqual, "test dash 23")
|
||||
So(query.Result.Slug, ShouldEqual, "test-dash-23")
|
||||
So(query.Result.Id, ShouldEqual, savedDash.Id)
|
||||
So(query.Result.Uid, ShouldEqual, savedDash.Uid)
|
||||
So(query.Result.IsFolder, ShouldBeFalse)
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard by slug", func() {
|
||||
query := m.GetDashboardQuery{
|
||||
Slug: "test-dash-23",
|
||||
OrgId: 1,
|
||||
@ -49,6 +68,24 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
|
||||
So(query.Result.Title, ShouldEqual, "test dash 23")
|
||||
So(query.Result.Slug, ShouldEqual, "test-dash-23")
|
||||
So(query.Result.Id, ShouldEqual, savedDash.Id)
|
||||
So(query.Result.Uid, ShouldEqual, savedDash.Uid)
|
||||
So(query.Result.IsFolder, ShouldBeFalse)
|
||||
})
|
||||
|
||||
Convey("Should be able to get dashboard by uid", func() {
|
||||
query := m.GetDashboardQuery{
|
||||
Uid: savedDash.Uid,
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
err := GetDashboard(&query)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(query.Result.Title, ShouldEqual, "test dash 23")
|
||||
So(query.Result.Slug, ShouldEqual, "test-dash-23")
|
||||
So(query.Result.Id, ShouldEqual, savedDash.Id)
|
||||
So(query.Result.Uid, ShouldEqual, savedDash.Uid)
|
||||
So(query.Result.IsFolder, ShouldBeFalse)
|
||||
})
|
||||
|
||||
@ -109,6 +146,8 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
So(len(query.Result), ShouldEqual, 1)
|
||||
hit := query.Result[0]
|
||||
So(hit.Type, ShouldEqual, search.DashHitFolder)
|
||||
So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
|
||||
So(hit.FolderTitle, ShouldEqual, "")
|
||||
})
|
||||
|
||||
Convey("Should be able to search for a dashboard folder's children", func() {
|
||||
@ -124,6 +163,11 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
So(len(query.Result), ShouldEqual, 2)
|
||||
hit := query.Result[0]
|
||||
So(hit.Id, ShouldEqual, savedDash.Id)
|
||||
So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug))
|
||||
So(hit.FolderId, ShouldEqual, savedFolder.Id)
|
||||
So(hit.FolderUid, ShouldEqual, savedFolder.Uid)
|
||||
So(hit.FolderTitle, ShouldEqual, savedFolder.Title)
|
||||
So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
|
||||
})
|
||||
|
||||
Convey("Should be able to search for dashboard by dashboard ids", func() {
|
||||
@ -157,18 +201,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() {
|
||||
@ -260,205 +456,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,7 +44,7 @@ func TestGetDashboardVersion(t *testing.T) {
|
||||
|
||||
dashCmd := m.GetDashboardQuery{
|
||||
OrgId: savedDash.OrgId,
|
||||
Slug: savedDash.Slug,
|
||||
Uid: savedDash.Uid,
|
||||
}
|
||||
|
||||
err = GetDashboard(&dashCmd)
|
||||
|
@ -150,4 +150,25 @@ func addDashboardMigration(mg *Migrator) {
|
||||
mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{
|
||||
Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0",
|
||||
}))
|
||||
|
||||
mg.AddMigration("Add column uid in dashboard", NewAddColumnMigration(dashboardV2, &Column{
|
||||
Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Update uid column values in dashboard", new(RawSqlMigration).
|
||||
Sqlite("UPDATE dashboard SET uid=printf('%09d',id) WHERE uid IS NULL;").
|
||||
Postgres("UPDATE dashboard SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;").
|
||||
Mysql("UPDATE dashboard SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{
|
||||
Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex,
|
||||
}))
|
||||
}
|
||||
|
@ -101,11 +101,13 @@ func (sb *SearchBuilder) buildSelect() {
|
||||
sb.sql.WriteString(
|
||||
`SELECT
|
||||
dashboard.id,
|
||||
dashboard.uid,
|
||||
dashboard.title,
|
||||
dashboard.slug,
|
||||
dashboard_tag.term,
|
||||
dashboard.is_folder,
|
||||
dashboard.folder_id,
|
||||
folder.uid as folder_uid,
|
||||
folder.slug as folder_slug,
|
||||
folder.title as folder_title
|
||||
FROM `)
|
||||
|
15
pkg/util/shortid_generator.go
Normal file
15
pkg/util/shortid_generator.go
Normal file
@ -0,0 +1,15 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gen, _ := shortid.New(1, shortid.DefaultABC, 1)
|
||||
shortid.SetDefault(gen)
|
||||
}
|
||||
|
||||
// GenerateShortUid generates a short unique identifier.
|
||||
func GenerateShortUid() string {
|
||||
return shortid.MustGenerate()
|
||||
}
|
@ -23,7 +23,8 @@ describe('AlertRuleList', () => {
|
||||
.format(),
|
||||
evalData: {},
|
||||
executionError: '',
|
||||
dashboardUri: 'db/mygool',
|
||||
url: 'd/ufkcofof/my-goal',
|
||||
canEdit: true,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
|
||||
'fa-pause': !rule.isPaused,
|
||||
});
|
||||
|
||||
let ruleUrl = `dashboard/${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`;
|
||||
let ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
|
||||
|
||||
return (
|
||||
<li className="alert-rule-item">
|
||||
@ -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>
|
||||
);
|
||||
|
@ -21,7 +21,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
|
||||
className="alert-rule-item__name"
|
||||
>
|
||||
<a
|
||||
href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
|
||||
href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
|
||||
>
|
||||
<Highlighter
|
||||
highlightClassName="highlight-search-match"
|
||||
@ -80,18 +80,19 @@ 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"
|
||||
href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
|
||||
title="Edit alert rule"
|
||||
>
|
||||
<i
|
||||
|
@ -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;
|
||||
|
73
public/app/containers/ManageDashboards/FolderPermissions.tsx
Normal file
73
public/app/containers/ManageDashboards/FolderPermissions.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
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';
|
||||
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
@inject('nav', 'folder', 'view', 'permissions')
|
||||
@observer
|
||||
export class FolderPermissions extends Component<IContainerProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleAddPermission = this.handleAddPermission.bind(this);
|
||||
this.loadStore();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { permissions } = this.props;
|
||||
permissions.hideAddPermissions();
|
||||
}
|
||||
|
||||
loadStore() {
|
||||
const { nav, folder, view } = this.props;
|
||||
return folder.load(view.routeParams.get('uid') as string).then(res => {
|
||||
view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {});
|
||||
return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
|
||||
});
|
||||
}
|
||||
|
||||
handleAddPermission() {
|
||||
const { permissions } = this.props;
|
||||
permissions.toggleAddPermissions();
|
||||
}
|
||||
|
||||
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-action-bar">
|
||||
<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 className="page-action-bar__spacer" />
|
||||
<button
|
||||
className="btn btn-success pull-right"
|
||||
onClick={this.handleAddPermission}
|
||||
disabled={permissions.isAddPermissionsVisible}
|
||||
>
|
||||
<i className="fa fa-plus" /> Add Permission
|
||||
</button>
|
||||
</div>
|
||||
<SlideDown in={permissions.isAddPermissionsVisible}>
|
||||
<AddPermissions permissions={permissions} backendSrv={backendSrv} />
|
||||
</SlideDown>
|
||||
<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.getDashboardByUid.mockReturnValue(
|
||||
Promise.resolve({
|
||||
dashboard: {
|
||||
id: 1,
|
||||
title: 'Folder Name',
|
||||
},
|
||||
meta: {
|
||||
url: '/dashboards/f/uid/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);
|
||||
});
|
||||
});
|
155
public/app/containers/ManageDashboards/FolderSettings.tsx
Normal file
155
public/app/containers/ManageDashboards/FolderSettings.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
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('uid') as string).then(res => {
|
||||
this.formSnapshot = getSnapshot(folder);
|
||||
this.dashboard = res.dashboard;
|
||||
|
||||
view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {});
|
||||
|
||||
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,11 @@ 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', 'folder']);
|
||||
}
|
||||
|
37
public/app/core/components/Animations/SlideDown.tsx
Normal file
37
public/app/core/components/Animations/SlideDown.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
|
||||
// If this is not enough, pass in <SlideDown maxHeight="....
|
||||
const defaultDuration = 200;
|
||||
const defaultStyle = {
|
||||
transition: `max-height ${defaultDuration}ms ease-in-out`,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
|
||||
// There are 4 main states a Transition can be in:
|
||||
// ENTERING, ENTERED, EXITING, EXITED
|
||||
// https://reactcommunity.org/react-transition-group/
|
||||
const transitionStyles = {
|
||||
exited: { maxHeight: 0 },
|
||||
entering: { maxHeight: maxHeight },
|
||||
entered: { maxHeight: maxHeight, overflow: 'visible' },
|
||||
exiting: { maxHeight: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition in={inProp} timeout={defaultDuration}>
|
||||
{state => (
|
||||
<div
|
||||
style={{
|
||||
...defaultStyle,
|
||||
...transitionStyles[state],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
};
|
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,90 @@
|
||||
import React from 'react';
|
||||
import AddPermissions from './AddPermissions';
|
||||
import { RootStore } from 'app/stores/RootStore/RootStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('AddPermissions', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
let instance;
|
||||
|
||||
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' },
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn();
|
||||
|
||||
store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
|
||||
instance = wrapper.instance();
|
||||
return store.permissions.load(1, true, false);
|
||||
});
|
||||
|
||||
describe('when permission for a user is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const evt = {
|
||||
target: {
|
||||
value: 'User',
|
||||
},
|
||||
};
|
||||
const userItem = {
|
||||
id: 2,
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
instance.typeChanged(evt);
|
||||
instance.userPicked(userItem);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
|
||||
|
||||
wrapper.find('form').simulate('submit', { preventDefault() {} });
|
||||
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when permission for team is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const evt = {
|
||||
target: {
|
||||
value: 'Group',
|
||||
},
|
||||
};
|
||||
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
instance.typeChanged(evt);
|
||||
instance.teamPicked(teamItem);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
|
||||
|
||||
wrapper.find('form').simulate('submit', { preventDefault() {} });
|
||||
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backendSrv.post.mockClear();
|
||||
});
|
||||
});
|
151
public/app/core/components/Permissions/AddPermissions.tsx
Normal file
151
public/app/core/components/Permissions/AddPermissions.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
|
||||
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
export interface IProps {
|
||||
permissions: any;
|
||||
backendSrv: any;
|
||||
}
|
||||
@observer
|
||||
class AddPermissions extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.userPicked = this.userPicked.bind(this);
|
||||
this.teamPicked = this.teamPicked.bind(this);
|
||||
this.permissionPicked = this.permissionPicked.bind(this);
|
||||
this.typeChanged = this.typeChanged.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const { permissions } = this.props;
|
||||
permissions.resetNewType();
|
||||
}
|
||||
|
||||
typeChanged(evt) {
|
||||
const { value } = evt.target;
|
||||
const { permissions } = this.props;
|
||||
|
||||
permissions.setNewType(value);
|
||||
}
|
||||
|
||||
userPicked(user: User) {
|
||||
const { permissions } = this.props;
|
||||
if (!user) {
|
||||
permissions.newItem.setUser(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setUser(user.id, user.login);
|
||||
}
|
||||
|
||||
teamPicked(team: Team) {
|
||||
const { permissions } = this.props;
|
||||
if (!team) {
|
||||
permissions.newItem.setTeam(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setTeam(team.id, team.name);
|
||||
}
|
||||
|
||||
permissionPicked(permission: OptionWithDescription) {
|
||||
const { permissions } = this.props;
|
||||
return permissions.newItem.setPermission(permission.value);
|
||||
}
|
||||
|
||||
resetNewType() {
|
||||
const { permissions } = this.props;
|
||||
return permissions.resetNewType();
|
||||
}
|
||||
|
||||
handleSubmit(evt) {
|
||||
evt.preventDefault();
|
||||
const { permissions } = this.props;
|
||||
permissions.addStoreItem();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { permissions, backendSrv } = this.props;
|
||||
const newItem = permissions.newItem;
|
||||
const pickerClassName = 'width-20';
|
||||
|
||||
const isValid = newItem.isValid();
|
||||
|
||||
return (
|
||||
<div className="gf-form-inline cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<form name="addPermission" onSubmit={this.handleSubmit}>
|
||||
<h6>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={newItem.type} onChange={this.typeChanged}>
|
||||
{aclTypes.map((option, idx) => {
|
||||
return (
|
||||
<option key={idx} value={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newItem.type === 'User' ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker
|
||||
backendSrv={backendSrv}
|
||||
handlePicked={this.userPicked}
|
||||
value={newItem.userId}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{newItem.type === 'Group' ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker
|
||||
backendSrv={backendSrv}
|
||||
handlePicked={this.teamPicked}
|
||||
value={newItem.teamId}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={this.permissionPicked}
|
||||
value={newItem.permission}
|
||||
disabled={false}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form">
|
||||
<button data-save-permission className="btn btn-success" type="submit" disabled={!isValid}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddPermissions;
|
@ -0,0 +1,70 @@
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-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';
|
||||
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface IProps {
|
||||
dashboardId: number;
|
||||
folder?: FolderInfo;
|
||||
backendSrv: any;
|
||||
}
|
||||
@observer
|
||||
class DashboardPermissions extends Component<IProps, any> {
|
||||
permissions: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleAddPermission = this.handleAddPermission.bind(this);
|
||||
this.permissions = store.permissions;
|
||||
}
|
||||
|
||||
handleAddPermission() {
|
||||
this.permissions.toggleAddPermissions();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.permissions.hideAddPermissions();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboardId, folder, backendSrv } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dashboard-settings__header">
|
||||
<div className="page-action-bar">
|
||||
<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 className="page-action-bar__spacer" />
|
||||
<button
|
||||
className="btn btn-success pull-right"
|
||||
onClick={this.handleAddPermission}
|
||||
disabled={this.permissions.isAddPermissionsVisible}
|
||||
>
|
||||
<i className="fa fa-plus" /> Add Permission
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SlideDown in={this.permissions.isAddPermissionsVisible}>
|
||||
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
|
||||
</SlideDown>
|
||||
<Permissions
|
||||
permissions={this.permissions}
|
||||
isFolder={false}
|
||||
dashboardId={dashboardId}
|
||||
folderInfo={folder}
|
||||
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 {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
92
public/app/core/components/Permissions/Permissions.tsx
Normal file
92
public/app/core/components/Permissions/Permissions.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsList from './PermissionsList';
|
||||
import { observer } from 'mobx-react';
|
||||
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.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);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { permissions, folderInfo } = this.props;
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<PermissionsList
|
||||
permissions={permissions.items}
|
||||
removeItem={this.removeItem}
|
||||
permissionChanged={this.permissionChanged}
|
||||
fetching={permissions.fetching}
|
||||
folderInfo={folderInfo}
|
||||
/>
|
||||
</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={`${folderInfo.url}/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();
|
||||
});
|
||||
});
|
19
public/app/core/components/Picker/TeamPicker.jest.tsx
Normal file
19
public/app/core/components/Picker/TeamPicker.jest.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import TeamPicker from './TeamPicker';
|
||||
|
||||
const model = {
|
||||
backendSrv: {
|
||||
get: () => {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('TeamPicker', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<TeamPicker {...model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
84
public/app/core/components/Picker/TeamPicker.tsx
Normal file
84
public/app/core/components/Picker/TeamPicker.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
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;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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, value, className } = 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 teams found"
|
||||
onChange={handlePicked}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
value={value}
|
||||
autosize={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(TeamPicker);
|
@ -8,8 +8,7 @@ const model = {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
refreshList: () => {},
|
||||
teamId: '1',
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('UserPicker', () => {
|
82
public/app/core/components/Picker/UserPicker.tsx
Normal file
82
public/app/core/components/Picker/UserPicker.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
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;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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, value, className } = 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={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
value={value}
|
||||
autosize={true}
|
||||
/>
|
||||
</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]}
|
@ -0,0 +1,98 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TeamPicker renders correctly 1`] = `
|
||||
<div
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
style={undefined}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
style={undefined}
|
||||
>
|
||||
<span
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-describedby={undefined}
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-label={undefined}
|
||||
aria-labelledby={undefined}
|
||||
aria-owns=""
|
||||
className={undefined}
|
||||
id={undefined}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
tabIndex={undefined}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -5,7 +5,7 @@ exports[`UserPicker renders correctly 1`] = `
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select width-8 gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
style={undefined}
|
||||
>
|
||||
<div
|
34
public/app/core/components/Picker/withPicker.tsx
Normal file
34
public/app/core/components/Picker/withPicker.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
handlePicked: (data) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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;
|
@ -83,6 +83,10 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
body.toggleClass('sidemenu-hidden');
|
||||
});
|
||||
|
||||
scope.$watch(() => playlistSrv.isPlaying, function(newValue) {
|
||||
elem.toggleClass('playlist-active', newValue === true);
|
||||
});
|
||||
|
||||
// tooltip removal fix
|
||||
// manage page classes
|
||||
var pageClass;
|
||||
|
@ -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;
|
||||
folderUid?: 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.folderUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => {
|
||||
this.canSave = dash.meta.canSave;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initDashboardList(result: any) {
|
||||
@ -91,10 +130,10 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
for (const section of this.sections) {
|
||||
if (section.checked && section.id !== 0) {
|
||||
selectedDashboards.folders.push(section.slug);
|
||||
selectedDashboards.folders.push(section.uid);
|
||||
} else {
|
||||
const selected = _.filter(section.items, { checked: true });
|
||||
selectedDashboards.dashboards.push(..._.map(selected, 'slug'));
|
||||
selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,8 +179,8 @@ export class ManageDashboardsCtrl {
|
||||
});
|
||||
}
|
||||
|
||||
private deleteFoldersAndDashboards(slugs) {
|
||||
this.backendSrv.deleteDashboards(slugs).then(result => {
|
||||
private deleteFoldersAndDashboards(uids) {
|
||||
this.backendSrv.deleteDashboards(uids).then(result => {
|
||||
const folders = _.filter(result, dash => dash.meta.isFolder);
|
||||
const folderCount = folders.length;
|
||||
const dashboards = _.filter(result, dash => !dash.meta.isFolder);
|
||||
@ -176,7 +215,7 @@ export class ManageDashboardsCtrl {
|
||||
appEvents.emit('alert-success', [header, msg]);
|
||||
}
|
||||
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
@ -185,7 +224,7 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
for (const section of this.sections) {
|
||||
const selected = _.filter(section.items, { checked: true });
|
||||
selectedDashboards.push(..._.map(selected, 'slug'));
|
||||
selectedDashboards.push(..._.map(selected, 'uid'));
|
||||
}
|
||||
|
||||
return selectedDashboards;
|
||||
@ -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: '=',
|
||||
folderUid: '=',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -7,25 +7,29 @@ export function geminiScrollbar() {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem, attrs) {
|
||||
let scrollbar = new PerfectScrollbar(elem[0]);
|
||||
let lastPos = 0;
|
||||
|
||||
appEvents.on(
|
||||
'smooth-scroll-top',
|
||||
() => {
|
||||
elem.animate(
|
||||
{
|
||||
scrollTop: 0,
|
||||
},
|
||||
500
|
||||
);
|
||||
'dash-scroll',
|
||||
evt => {
|
||||
if (evt.restore) {
|
||||
elem[0].scrollTop = lastPos;
|
||||
return;
|
||||
}
|
||||
|
||||
lastPos = elem[0].scrollTop;
|
||||
|
||||
if (evt.animate) {
|
||||
elem.animate({ scrollTop: evt.pos }, 500);
|
||||
} else {
|
||||
elem[0].scrollTop = evt.pos;
|
||||
}
|
||||
},
|
||||
scope
|
||||
);
|
||||
|
||||
scope.$on('$routeChangeSuccess', () => {
|
||||
elem[0].scrollTop = 0;
|
||||
});
|
||||
|
||||
scope.$on('$routeUpdate', () => {
|
||||
lastPos = 0;
|
||||
elem[0].scrollTop = 0;
|
||||
});
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user