Merge branch 'docs_v5.0' of github.com:grafana/grafana into docs_v5.0

This commit is contained in:
Torkel Ödegaard
2018-02-05 15:37:54 +01:00
153 changed files with 3227 additions and 1077 deletions

View File

@@ -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
View File

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

View File

@@ -193,3 +193,7 @@ ignored = [
non-go = true
go-tests = true
unused-packages = true
[[constraint]]
branch = "master"
name = "github.com/teris-io/shortid"

View File

@@ -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,6 +52,8 @@ 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
@@ -63,12 +65,43 @@ Permission levels:
- **Admin**: Can edit & create dashboards and edit permissions.
- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
- **View**: Can only view existing dashboards/folders.
#### Restricting Access
The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL).
- You cannot override permissions for users with the **Org Admin Role**. Admins always have access to everything.
- A more specific permission with a lower permission level will not have any effect if a more general rule exists with higher permission level. You need to remove or lower the permission level of the more general rule.
#### How Grafana Resolves Multiple Permissions - Examples
##### Example 1 (`user1` has the Editor Role)
Permissions for a dashboard:
- `Everyone with Editor Role Can Edit`
- `user1 Can View`
Result: `user1` has Edit permission as the highest permission always wins.
##### Example 2 (`user1` has the Viewer Role and is a member of `team1`)
Permissions for a dashboard:
- `Everyone with Viewer Role Can View`
- `user1 Can Edit`
- `team1 Can Admin`
Result: `user1` has Admin permission as the highest permission always wins.
##### Example 3
Permissions for a dashboard:
- `user1 Can Admin (inherited from parent folder)`
- `user1 Can Edit`
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.
#### Restricting access
The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the
Access Control List (ACL).
- You cannot override permissions for users with **Org Admin Role**
- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.

View File

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

View File

@@ -62,7 +62,7 @@ Content-Type: application/json
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -94,7 +94,7 @@ Content-Type: application/json
```http
HTTP/1.1 200
Content-Type: application/json
{
{
```
## Pause all alerts
@@ -196,7 +196,7 @@ Content-Type: application/json
**Example Request**:
```http
DELETE /api/alert-notifications/1 HTTP/1.1
DELETE /api/alert-notifications/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -294,4 +294,4 @@ Content-Type: application/json
{
"message": "Notification deleted"
}
```
```

View File

@@ -90,7 +90,7 @@ Content-Type: application/json
**Example Response**:
```http
```http
HTTP/1.1 200
Content-Type: application/json

View File

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

View File

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

View File

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

View File

@@ -115,6 +115,10 @@
"*.scss": [
"prettier --write",
"git add"
],
"*.go": [
"gofmt -w -s",
"git add"
]
},
"prettier": {
@@ -153,6 +157,7 @@
"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",

View File

@@ -105,7 +105,8 @@ func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.Ale
for _, alert := range alertDTOs {
for _, dash := range dashboardsQuery.Result {
if alert.DashboardId == dash.Id {
alert.DashbboardUri = "db/" + dash.Slug
alert.Url = dash.GenerateUrl()
break
}
}
}

View File

@@ -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)
@@ -242,6 +248,9 @@ 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", wrap(DeleteDashboard))

View File

@@ -44,7 +44,7 @@ func dashboardGuardianResponse(err error) Response {
}
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
}
@@ -88,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
@@ -98,7 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
return ApiError(500, "Dashboard folder could not be read", err)
}
meta.FolderTitle = query.Result.Title
meta.FolderSlug = query.Result.Slug
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
}
@@ -208,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 {
@@ -232,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 {
@@ -243,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())
@@ -262,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 {
@@ -400,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
}
@@ -423,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)

View File

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

View File

@@ -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
})
@@ -77,9 +86,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
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)
@@ -87,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) {
@@ -111,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)
@@ -121,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) {
@@ -137,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() {
@@ -172,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,
@@ -185,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
})
@@ -215,18 +294,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
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) {
@@ -248,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) {
@@ -290,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)
@@ -300,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) {
@@ -316,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)
})
})
@@ -334,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)
@@ -344,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")
})
})
})
@@ -362,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)
@@ -372,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) {
@@ -388,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)
})
})
@@ -405,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) {
@@ -435,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 {
@@ -479,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
@@ -496,6 +796,18 @@ 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()
@@ -518,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
}

View File

@@ -19,7 +19,7 @@ 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"`
}

View File

@@ -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,7 +27,7 @@ type DashboardMeta struct {
IsFolder bool `json:"isFolder"`
FolderId int64 `json:"folderId"`
FolderTitle string `json:"folderTitle"`
FolderSlug string `json:"folderSlug"`
FolderUrl string `json:"folderUrl"`
}
type DashboardFullWithMeta struct {

View File

@@ -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() {

View File

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

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

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

View File

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

View File

@@ -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"`
}
//

View File

@@ -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
@@ -218,6 +263,13 @@ type GetDashboardSlugByIdQuery struct {
Result string
}
type GetDashboardsBySlugQuery struct {
OrgId int64
Slug string
Result []*Dashboard
}
type GetFoldersForSignedInUserQuery struct {
OrgId int64
SignedInUser *SignedInUser
@@ -235,3 +287,13 @@ type DashboardPermissionForUser struct {
Permission PermissionType `json:"permission"`
PermissionName string `json:"permissionName"`
}
type DashboardRef struct {
Uid string
Slug string
}
type GetDashboardRefByIdQuery struct {
Id int64
Result *DashboardRef
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/util"
)
func init() {
@@ -18,19 +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
@@ -52,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
@@ -92,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 {
@@ -142,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 {
@@ -168,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 {
@@ -178,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
}
@@ -261,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
}
@@ -316,16 +379,19 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
params = append(params, query.SignedInUser.UserId)
params = append(params, query.OrgId)
sql += `WHERE
sql += ` WHERE
d.org_id = ? AND
d.is_folder = 1 AND
d.is_folder = ? AND
(
(d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
OR (d.has_acl = 0 AND ouRole.id IS NOT NULL)
(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() + " ?"
@@ -333,7 +399,6 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
}
sql += ` ORDER BY d.title ASC`
err = x.Sql(sql, params...).Find(&query.Result)
}
@@ -430,9 +495,9 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
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
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 {
@@ -447,13 +512,15 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
)
group by d.id
order by d.id asc`
params = append(params, dialect.BooleanStr(true))
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()
@@ -484,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
@@ -495,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
}

View File

@@ -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,6 +126,11 @@ 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 {
var err error
@@ -141,7 +147,11 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.updated,
'' as user_login,
'' as user_email,
'' as team
'' 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)
@@ -155,6 +165,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
)`, query.DashboardId, query.DashboardId)
rawSQL := `
-- get permissions for the dashboard and its parent folder
SELECT
da.id,
da.org_id,
@@ -167,13 +178,18 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.updated,
u.login AS user_login,
u.email AS user_email,
ug.name AS team
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 = ?
-- Also include default permission if has_acl = 0
-- Also include default permissions if folder or dashboard field "has_acl" is false
UNION
SELECT
@@ -188,10 +204,14 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.updated,
'' as user_login,
'' as user_email,
'' as team
FROM dashboard_acl as da,
'' as team,
folder.title,
folder.slug,
folder.uid,
folder.is_folder
FROM dashboard_acl as da,
dashboard as dash
LEFT JOIN dashboard folder on dash.folder_id = folder.id
LEFT OUTER JOIN dashboard folder on dash.folder_id = folder.id
WHERE
dash.id = ? AND (
dash.has_acl = ` + dialect.BooleanStr(false) + ` or

View File

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

View File

@@ -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,336 +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 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)
})
})
})
Convey("Given a plugin with imported dashboards", func() {
pluginId := "test-app"

View File

@@ -12,7 +12,7 @@ import (
)
func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
data["title"] = dashboard.Title
data["uid"] = dashboard.Uid
saveCmd := m.SaveDashboardCommand{
OrgId: dashboard.OrgId,
@@ -44,7 +44,7 @@ func TestGetDashboardVersion(t *testing.T) {
dashCmd := m.GetDashboardQuery{
OrgId: savedDash.OrgId,
Slug: savedDash.Slug,
Uid: savedDash.Uid,
}
err = GetDashboard(&dashCmd)

View File

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

View File

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

View 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()
}

View File

@@ -23,7 +23,7 @@ describe('AlertRuleList', () => {
.format(),
evalData: {},
executionError: '',
dashboardUri: 'db/mygool',
url: 'd/ufkcofof/my-goal',
canEdit: true,
},
])

View File

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

View File

@@ -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"
@@ -92,7 +92,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
</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

View File

@@ -6,21 +6,35 @@ 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('slug') as string).then(res => {
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;
@@ -34,13 +48,23 @@ export class FolderPermissions extends Component<IContainerProps, any> {
<div>
<PageHeader model={nav as any} />
<div className="page-container page-body">
<div className="page-sub-heading">
<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>

View File

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

View File

@@ -20,10 +20,12 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
loadStore() {
const { nav, folder, view } = this.props;
return folder.load(view.routeParams.get('slug') as string).then(res => {
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');
});
}
@@ -51,7 +53,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
folder
.saveFolder(this.dashboard, { overwrite: false })
.then(newUrl => {
view.updatePathAndQuery(newUrl, '', '');
view.updatePathAndQuery(newUrl, {}, {});
appEvents.emit('dashboard-saved');
appEvents.emit('alert-success', ['Folder saved']);

View File

@@ -20,11 +20,5 @@ export function registerAngularDirectives() {
['tagOptions', { watchDepth: 'reference' }],
]);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
react2AngularDirective('dashboardPermissions', DashboardPermissions, [
'backendSrv',
'dashboardId',
'folderTitle',
'folderSlug',
'folderId',
]);
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
}

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

View File

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

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

View File

@@ -1,41 +1,65 @@
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;
folderId: number;
folderTitle: string;
folderSlug: string;
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, folderTitle, folderSlug, folderId, backendSrv } = this.props;
const { dashboardId, folder, backendSrv } = this.props;
return (
<div>
<div className="dashboard-settings__header">
<h3 className="d-inline-block">Permissions</h3>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
<div 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={{ title: folderTitle, slug: folderSlug, id: folderId }}
folderInfo={folder}
backendSrv={backendSrv}
/>
</div>

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
import React, { Component } from 'react';
import React, { Component } from 'react';
import PermissionsList from './PermissionsList';
import { observer } from 'mobx-react';
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
import { FolderInfo } from './FolderInfo';
export interface DashboardAcl {
@@ -40,8 +37,6 @@ class Permissions extends Component<IProps, any> {
this.permissionChanged = this.permissionChanged.bind(this);
this.typeChanged = this.typeChanged.bind(this);
this.removeItem = this.removeItem.bind(this);
this.userPicked = this.userPicked.bind(this);
this.teamPicked = this.teamPicked.bind(this);
this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
}
@@ -77,28 +72,8 @@ class Permissions extends Component<IProps, any> {
permissions.setNewType(value);
}
userPicked(user: User) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
userId: user.id,
userLogin: user.login,
permission: 1,
dashboardId: dashboardId,
});
}
teamPicked(team: Team) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
teamId: team.id,
team: team.name,
permission: 1,
dashboardId: dashboardId,
});
}
render() {
const { permissions, folderInfo, backendSrv } = this.props;
const { permissions, folderInfo } = this.props;
return (
<div className="gf-form-group">
@@ -109,50 +84,6 @@ class Permissions extends Component<IProps, any> {
fetching={permissions.fetching}
folderInfo={folderInfo}
/>
<div className="gf-form-inline">
<form name="addPermission" className="gf-form-group">
<h6 className="muted">Add Permission For</h6>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={permissions.newType}
onChange={this.typeChanged}
>
{aclTypes.map((option, idx) => {
return (
<option key={idx} value={option.value}>
{option.text}
</option>
);
})}
</select>
</div>
</div>
{permissions.newType === 'User' ? (
<div className="gf-form">
<UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
</div>
) : null}
{permissions.newType === 'Group' ? (
<div className="gf-form">
<TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
</div>
) : null}
</div>
</form>
{permissions.error ? (
<div className="gf-form width-17">
<span ng-if="ctrl.error" className="text-error p-l-1">
<i className="fa fa-warning" />
{permissions.error}
</span>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -30,7 +30,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
folderInfo && (
<em className="muted no-wrap">
Inherited from folder{' '}
<a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
<a className="text-link" href={`${folderInfo.url}/permissions`}>
{folderInfo.title}
</a>{' '}
</em>

View 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();
});
});

View File

@@ -9,6 +9,8 @@ export interface IProps {
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
value?: string;
className?: string;
}
export interface Team {
@@ -54,7 +56,7 @@ class TeamPicker extends Component<IProps, any> {
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
const { isLoading, handlePicked, value, className } = this.props;
return (
<div className="user-picker">
@@ -66,10 +68,13 @@ class TeamPicker extends Component<IProps, any> {
isLoading={isLoading}
loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..."
noResultsText="No teams found"
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={PickerOption}
placeholder="Choose"
value={value}
autosize={true}
/>
</div>
);

View File

@@ -9,6 +9,8 @@ export interface IProps {
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
value?: string;
className?: string;
}
export interface User {
@@ -53,8 +55,7 @@ class UserPicker extends Component<IProps, any> {
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
const { isLoading, handlePicked, value, className } = this.props;
return (
<div className="user-picker">
<AsyncComponent
@@ -67,9 +68,11 @@ class UserPicker extends Component<IProps, any> {
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={PickerOption}
placeholder="Choose"
value={value}
autosize={true}
/>
</div>
);

View File

@@ -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>
`;

View File

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

View File

@@ -3,6 +3,8 @@
export interface IProps {
backendSrv: any;
handlePicked: (data) => void;
value?: string;
className?: string;
}
export default function withPicker(WrappedComponent) {

View File

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

View File

@@ -34,7 +34,7 @@ export class ManageDashboardsCtrl {
// used when managing dashboards for a specific folder
folderId?: number;
folderSlug?: string;
folderUid?: string;
// if user can add new folders and/or add new dashboards
canSave: boolean;
@@ -74,11 +74,11 @@ export class ManageDashboardsCtrl {
return this.initDashboardList(result);
})
.then(() => {
if (!this.folderSlug) {
if (!this.folderUid) {
return;
}
return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => {
this.canSave = dash.meta.canSave;
});
});
@@ -130,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'));
}
}
@@ -179,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);
@@ -224,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;
@@ -334,7 +334,7 @@ export function manageDashboardsDirective() {
controllerAs: 'ctrl',
scope: {
folderId: '=',
folderSlug: '=',
folderUid: '=',
},
};
}

View File

@@ -18,10 +18,6 @@ function (_, $, coreModule) {
elem.toggleClass('panel-in-fullscreen', false);
});
$scope.$watch('ctrl.playlistSrv.isPlaying', function(newValue) {
elem.toggleClass('playlist-active', newValue === true);
});
$scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
if (newValue) {
elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));

View File

@@ -225,6 +225,10 @@ export class BackendSrv {
return this.get('/api/dashboards/' + type + '/' + slug);
}
getDashboardByUid(uid: string) {
return this.get(`/api/dashboards/uid/${uid}`);
}
saveDashboard(dash, options) {
options = options || {};
@@ -253,11 +257,22 @@ export class BackendSrv {
});
}
deleteDashboard(slug) {
saveFolder(dash, options) {
options = options || {};
return this.post('/api/dashboards/db/', {
dashboard: dash,
isFolder: true,
overwrite: options.overwrite === true,
message: options.message || '',
});
}
deleteDashboard(uid) {
let deferred = this.$q.defer();
this.getDashboard('db', slug).then(fullDash => {
this.delete(`/api/dashboards/db/${slug}`)
this.getDashboardByUid(uid).then(fullDash => {
this.delete(`/api/dashboards/uid/${uid}`)
.then(() => {
deferred.resolve(fullDash);
})
@@ -269,21 +284,21 @@ export class BackendSrv {
return deferred.promise;
}
deleteDashboards(dashboardSlugs) {
deleteDashboards(dashboardUids) {
const tasks = [];
for (let slug of dashboardSlugs) {
tasks.push(this.createTask(this.deleteDashboard.bind(this), true, slug));
for (let uid of dashboardUids) {
tasks.push(this.createTask(this.deleteDashboard.bind(this), true, uid));
}
return this.executeInOrder(tasks, []);
}
moveDashboards(dashboardSlugs, toFolder) {
moveDashboards(dashboardUids, toFolder) {
const tasks = [];
for (let slug of dashboardSlugs) {
tasks.push(this.createTask(this.moveDashboard.bind(this), true, slug, toFolder));
for (let uid of dashboardUids) {
tasks.push(this.createTask(this.moveDashboard.bind(this), true, uid, toFolder));
}
return this.executeInOrder(tasks, []).then(result => {
@@ -295,10 +310,10 @@ export class BackendSrv {
});
}
private moveDashboard(slug, toFolder) {
private moveDashboard(uid, toFolder) {
let deferred = this.$q.defer();
this.getDashboard('db', slug).then(fullDash => {
this.getDashboardByUid(uid).then(fullDash => {
const model = new DashboardModel(fullDash.dashboard, fullDash.meta);
if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) {

View File

@@ -1,30 +1,18 @@
import coreModule from 'app/core/core_module';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { store } from 'app/stores/store';
import { reaction } from 'mobx';
import locationUtil from 'app/core/utils/location_util';
// Services that handles angular -> mobx store sync & other react <-> angular sync
export class BridgeSrv {
private appSubUrl;
private fullPageReloadRoutes;
/** @ngInject */
constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
this.appSubUrl = config.appSubUrl;
this.fullPageReloadRoutes = ['/logout'];
}
// Angular's $location does not like <base href...> and absolute urls
stripBaseFromUrl(url = '') {
const appSubUrl = this.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =
url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url;
return urlWithoutBase;
}
init() {
this.$rootScope.$on('$routeUpdate', (evt, data) => {
let angularUrl = this.$location.url();
@@ -34,25 +22,25 @@ export class BridgeSrv {
});
this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
let angularUrl = this.$location.url();
if (store.view.currentUrl !== angularUrl) {
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
}
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
});
reaction(
() => store.view.currentUrl,
currentUrl => {
let angularUrl = this.$location.url();
if (angularUrl !== currentUrl) {
this.$location.url(currentUrl);
console.log('store updating angular $location.url', currentUrl);
const url = locationUtil.stripBaseFromUrl(currentUrl);
if (angularUrl !== url) {
this.$timeout(() => {
this.$location.url(url);
});
console.log('store updating angular $location.url', url);
}
}
);
appEvents.on('location-change', payload => {
const urlWithoutBase = this.stripBaseFromUrl(payload.href);
const urlWithoutBase = locationUtil.stripBaseFromUrl(payload.href);
if (this.fullPageReloadRoutes.indexOf(urlWithoutBase) > -1) {
this.$window.location.href = payload.href;
return;

View File

@@ -41,10 +41,7 @@ export class SearchSrv {
.map(orderId => {
return _.find(result, { id: orderId });
})
.filter(hit => hit && !hit.isStarred)
.map(hit => {
return this.transformToViewModel(hit);
});
.filter(hit => hit && !hit.isStarred);
});
}
@@ -81,17 +78,12 @@ export class SearchSrv {
score: -2,
expanded: this.starredIsOpen,
toggle: this.toggleStarred.bind(this),
items: result.map(this.transformToViewModel),
items: result,
};
}
});
}
private transformToViewModel(hit) {
hit.url = 'dashboard/db/' + hit.slug;
return hit;
}
search(options) {
let sections: any = {};
let promises = [];
@@ -136,12 +128,12 @@ export class SearchSrv {
if (hit.type === 'dash-folder') {
sections[hit.id] = {
id: hit.id,
uid: hit.uid,
title: hit.title,
expanded: false,
items: [],
toggle: this.toggleFolder.bind(this),
url: `dashboards/folder/${hit.id}/${hit.slug}`,
slug: hit.slug,
url: hit.url,
icon: 'fa fa-folder',
score: _.keys(sections).length,
};
@@ -158,9 +150,9 @@ export class SearchSrv {
if (hit.folderId) {
section = {
id: hit.folderId,
uid: hit.folderUid,
title: hit.folderTitle,
url: `dashboards/folder/${hit.folderId}/${hit.folderSlug}`,
slug: hit.slug,
url: hit.folderUrl,
items: [],
icon: 'fa fa-folder-open',
toggle: this.toggleFolder.bind(this),
@@ -169,7 +161,7 @@ export class SearchSrv {
} else {
section = {
id: 0,
title: 'Root',
title: 'General',
items: [],
icon: 'fa fa-folder-open',
toggle: this.toggleFolder.bind(this),
@@ -181,7 +173,7 @@ export class SearchSrv {
}
section.expanded = true;
section.items.push(this.transformToViewModel(hit));
section.items.push(hit);
}
}
@@ -198,7 +190,7 @@ export class SearchSrv {
};
return this.backendSrv.search(query).then(results => {
section.items = _.map(results, this.transformToViewModel);
section.items = results;
return Promise.resolve(section);
});
}

View File

@@ -1,22 +0,0 @@
import { BridgeSrv } from 'app/core/services/bridge_srv';
jest.mock('app/core/config', () => {
return {
appSubUrl: '/subUrl',
};
});
describe('BridgeSrv', () => {
let searchSrv;
beforeEach(() => {
searchSrv = new BridgeSrv(null, null, null, null, null);
});
describe('With /subUrl as appSubUrl', () => {
it('/subUrl should be stripped', () => {
const urlWithoutMaster = searchSrv.stripBaseFromUrl('/subUrl/grafana/');
expect(urlWithoutMaster).toBe('/grafana/');
});
});
});

View File

@@ -0,0 +1,16 @@
import locationUtil from 'app/core/utils/location_util';
jest.mock('app/core/config', () => {
return {
appSubUrl: '/subUrl',
};
});
describe('locationUtil', () => {
describe('With /subUrl as appSubUrl', () => {
it('/subUrl should be stripped', () => {
const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');
expect(urlWithoutMaster).toBe('/grafana/');
});
});
});

View File

@@ -20,9 +20,6 @@ describe('ManageDashboards', () => {
icon: 'fa fa-folder',
tags: [],
isStarred: false,
folderId: 410,
folderTitle: 'afolder',
folderSlug: 'afolder',
},
],
tags: [],
@@ -30,7 +27,7 @@ describe('ManageDashboards', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
icon: 'fa fa-folder-open',
uri: 'db/something-else',
type: 'dash-db',
@@ -77,9 +74,6 @@ describe('ManageDashboards', () => {
icon: 'fa fa-folder',
tags: [],
isStarred: false,
folderId: 410,
folderTitle: 'afolder',
folderSlug: 'afolder',
},
],
tags: [],
@@ -112,8 +106,9 @@ describe('ManageDashboards', () => {
tags: [],
isStarred: false,
folderId: 410,
folderTitle: 'afolder',
folderSlug: 'afolder',
folderUid: 'uid',
folderTitle: 'Folder',
folderUrl: '/dashboards/f/uid/folder',
},
{
id: 500,
@@ -363,7 +358,7 @@ describe('ManageDashboards', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
items: [{ id: 3, checked: true }],
checked: false,
},
@@ -391,7 +386,7 @@ describe('ManageDashboards', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
items: [{ id: 3, checked: false }],
checked: false,
},
@@ -420,7 +415,7 @@ describe('ManageDashboards', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
items: [{ id: 3, checked: true }],
checked: false,
},
@@ -455,7 +450,7 @@ describe('ManageDashboards', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
items: [{ id: 3, checked: false }],
checked: false,
},
@@ -483,22 +478,22 @@ describe('ManageDashboards', () => {
ctrl.sections = [
{
id: 1,
uid: 'folder',
title: 'folder',
items: [{ id: 2, checked: true, slug: 'folder-dash' }],
items: [{ id: 2, checked: true, uid: 'folder-dash' }],
checked: true,
slug: 'folder',
},
{
id: 3,
title: 'folder-2',
items: [{ id: 3, checked: true, slug: 'folder-2-dash' }],
items: [{ id: 3, checked: true, uid: 'folder-2-dash' }],
checked: false,
slug: 'folder-2',
uid: 'folder-2',
},
{
id: 0,
title: 'Root',
items: [{ id: 3, checked: true, slug: 'root-dash' }],
title: 'General',
items: [{ id: 3, checked: true, uid: 'root-dash' }],
checked: true,
},
];
@@ -535,14 +530,14 @@ describe('ManageDashboards', () => {
{
id: 1,
title: 'folder',
items: [{ id: 2, checked: true, slug: 'dash' }],
items: [{ id: 2, checked: true, uid: 'dash' }],
checked: false,
slug: 'folder',
uid: 'folder',
},
{
id: 0,
title: 'Root',
items: [{ id: 3, checked: true, slug: 'dash-2' }],
title: 'General',
items: [{ id: 3, checked: true, uid: 'dash-2' }],
checked: false,
},
];

View File

@@ -49,7 +49,7 @@ describe('SearchCtrl', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
items: [{ id: 3, selected: false }, { id: 5, selected: false }],
selected: false,
expanded: true,
@@ -146,7 +146,7 @@ describe('SearchCtrl', () => {
},
{
id: 0,
title: 'Root',
title: 'General',
items: [{ id: 3, selected: false }, { id: 5, selected: false }],
selected: false,
expanded: true,

View File

@@ -190,7 +190,9 @@ describe('SearchSrv', () => {
title: 'dash in folder1 1',
type: 'dash-db',
folderId: 1,
folderUid: 'uid',
folderTitle: 'folder1',
folderUrl: '/dashboards/f/uid/folder1',
},
])
);
@@ -206,6 +208,11 @@ describe('SearchSrv', () => {
it('should group results by folder', () => {
expect(results).toHaveLength(2);
expect(results[0].id).toEqual(0);
expect(results[1].id).toEqual(1);
expect(results[1].uid).toEqual('uid');
expect(results[1].title).toEqual('folder1');
expect(results[1].url).toEqual('/dashboards/f/uid/folder1');
});
});

View File

@@ -0,0 +1,14 @@
import config from 'app/core/config';
const _stripBaseFromUrl = url => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =
url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url;
return urlWithoutBase;
};
export default {
stripBaseFromUrl: _stripBaseFromUrl,
};

View File

@@ -19,9 +19,7 @@ export class CreateFolderCtrl {
return this.backendSrv.createDashboardFolder(this.title).then(result => {
appEvents.emit('alert-success', ['Folder Created', 'OK']);
var folderUrl = `dashboards/folder/${result.dashboard.id}/${result.meta.slug}`;
this.$location.url(folderUrl);
this.$location.url(result.meta.url);
});
}
@@ -29,7 +27,7 @@ export class CreateFolderCtrl {
this.titleTouched = true;
this.validationSrv
.validateNewDashboardOrFolderName(this.title)
.validateNewFolderName(this.title)
.then(() => {
this.hasValidationError = false;
})

View File

@@ -93,7 +93,7 @@ export class DashboardImportCtrl {
this.nameExists = false;
this.validationSrv
.validateNewDashboardOrFolderName(this.dash.title)
.validateNewDashboardName(0, this.dash.title)
.then(() => {
this.hasNameValidationError = false;
})

View File

@@ -35,18 +35,18 @@ export class DashboardLoaderSrv {
};
}
loadDashboard(type, slug) {
loadDashboard(type, slug, uid) {
var promise;
if (type === 'script') {
promise = this._loadScriptedDashboard(slug);
} else if (type === 'snapshot') {
promise = this.backendSrv.get('/api/snapshots/' + this.$routeParams.slug).catch(() => {
promise = this.backendSrv.get('/api/snapshots/' + slug).catch(() => {
return this._dashboardLoadFailed('Snapshot not found', true);
});
} else {
promise = this.backendSrv
.getDashboard(this.$routeParams.type, this.$routeParams.slug)
.getDashboardByUid(uid)
.then(result => {
if (result.meta.isFolder) {
this.$rootScope.appEvent('alert-error', ['Dashboard not found']);

View File

@@ -12,6 +12,7 @@ import { DashboardMigrator } from './dashboard_migration';
export class DashboardModel {
id: any;
uid: any;
title: any;
autoUpdate: any;
description: any;
@@ -56,6 +57,7 @@ export class DashboardModel {
this.events = new Emitter();
this.id = data.id || null;
this.uid = data.uid || null;
this.revision = data.revision;
this.title = data.title || 'No Title';
this.autoUpdate = data.autoUpdate;
@@ -279,6 +281,40 @@ export class DashboardModel {
this.events.emit('repeats-processed');
}
cleanUpRowRepeats(rowPanels) {
let panelsToRemove = [];
for (let i = 0; i < rowPanels.length; i++) {
let panel = rowPanels[i];
if (!panel.repeat && panel.repeatPanelId) {
panelsToRemove.push(panel);
}
}
_.pull(rowPanels, ...panelsToRemove);
_.pull(this.panels, ...panelsToRemove);
}
processRowRepeats(row: PanelModel) {
if (this.snapshot || this.templating.list.length === 0) {
return;
}
let rowPanels = row.panels;
if (!row.collapsed) {
let rowPanelIndex = _.findIndex(this.panels, p => p.id === row.id);
rowPanels = this.getRowPanels(rowPanelIndex);
}
this.cleanUpRowRepeats(rowPanels);
for (let i = 0; i < rowPanels.length; i++) {
let panel = rowPanels[i];
if (panel.repeat) {
let panelIndex = _.findIndex(this.panels, p => p.id === panel.id);
this.repeatPanel(panel, panelIndex);
}
}
}
getPanelRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
// if first clone return source
if (valueIndex === 0) {
@@ -569,7 +605,7 @@ export class DashboardModel {
if (row.collapsed) {
row.collapsed = false;
let hasRepeat = false;
let hasRepeat = _.some(row.panels, p => p.repeat);
if (row.panels.length > 0) {
// Use first panel to figure out if it was moved or pushed
@@ -590,10 +626,6 @@ export class DashboardModel {
// update insert post and y max
insertPos += 1;
yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
if (panel.repeat) {
hasRepeat = true;
}
}
const pushDownAmount = yMax - row.gridPos.y;
@@ -606,7 +638,7 @@ export class DashboardModel {
row.panels = [];
if (hasRepeat) {
this.processRepeats();
this.processRowRepeats(row);
}
}

View File

@@ -73,9 +73,8 @@ export class DashboardSrv {
postSave(clone, data) {
this.dash.version = data.version;
var dashboardUrl = '/dashboard/db/' + data.slug;
if (dashboardUrl !== this.$location.path()) {
this.$location.url(dashboardUrl);
if (data.url !== this.$location.path()) {
this.$location.url(data.url);
}
this.$rootScope.appEvent('dashboard-saved', this.dash);

View File

@@ -1,19 +1,24 @@
import { FolderPageLoader } from './folder_page_loader';
import locationUtil from 'app/core/utils/location_util';
export class FolderDashboardsCtrl {
navModel: any;
folderId: number;
folderSlug: string;
uid: string;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams) {
if (this.$routeParams.folderId && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
const loader = new FolderPageLoader(this.backendSrv, this.$routeParams);
const loader = new FolderPageLoader(this.backendSrv);
loader.load(this, this.folderId, 'manage-folder-dashboards').then(result => {
this.folderSlug = result.meta.slug;
loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
const url = locationUtil.stripBaseFromUrl(folder.meta.url);
if (url !== $location.path()) {
$location.path(url).replace();
}
});
}
}

View File

@@ -1,7 +1,7 @@
export class FolderPageLoader {
constructor(private backendSrv, private $routeParams) {}
constructor(private backendSrv) {}
load(ctrl, folderId, activeChildId) {
load(ctrl, uid, activeChildId) {
ctrl.navModel = {
main: {
icon: 'fa fa-folder-open',
@@ -36,11 +36,12 @@ export class FolderPageLoader {
},
};
return this.backendSrv.getDashboard('db', this.$routeParams.slug).then(result => {
return this.backendSrv.getDashboardByUid(uid).then(result => {
ctrl.folderId = result.dashboard.id;
const folderTitle = result.dashboard.title;
const folderUrl = result.meta.url;
ctrl.navModel.main.text = folderTitle;
const folderUrl = this.createFolderUrl(folderId, result.meta.slug);
const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
dashTab.url = folderUrl;
@@ -57,8 +58,4 @@ export class FolderPageLoader {
return result;
});
}
createFolderUrl(folderId: number, slug: string) {
return `dashboards/folder/${folderId}/${slug}`;
}
}

View File

@@ -3,20 +3,23 @@ import { FolderPageLoader } from './folder_page_loader';
export class FolderPermissionsCtrl {
navModel: any;
folderId: number;
uid: string;
dashboard: any;
meta: any;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams) {
if (this.$routeParams.folderId && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
new FolderPageLoader(this.backendSrv, this.$routeParams)
.load(this, this.folderId, 'manage-folder-permissions')
.then(result => {
this.dashboard = result.dashboard;
this.meta = result.meta;
});
new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => {
if ($location.path() !== folder.meta.url) {
$location.path(`${folder.meta.url}/permissions`).replace();
}
this.dashboard = folder.dashboard;
this.meta = folder.meta;
});
}
}
}

View File

@@ -12,7 +12,7 @@ export class FolderPickerCtrl {
enterFolderCreation: any;
exitFolderCreation: any;
enableCreateNew: boolean;
rootName = 'Root';
rootName = 'General';
folder: any;
createNewFolder: boolean;
newFolderName: string;
@@ -33,10 +33,13 @@ export class FolderPickerCtrl {
return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
if (
query === '' ||
query.toLowerCase() === 'r' ||
query.toLowerCase() === 'ro' ||
query.toLowerCase() === 'roo' ||
query.toLowerCase() === 'root'
query.toLowerCase() === 'g' ||
query.toLowerCase() === 'ge' ||
query.toLowerCase() === 'gen' ||
query.toLowerCase() === 'gene' ||
query.toLowerCase() === 'gener' ||
query.toLowerCase() === 'genera' ||
query.toLowerCase() === 'general'
) {
result.unshift({ title: this.rootName, id: 0 });
}
@@ -64,7 +67,7 @@ export class FolderPickerCtrl {
this.newFolderNameTouched = true;
this.validationSrv
.validateNewDashboardOrFolderName(this.newFolderName)
.validateNewFolderName(this.newFolderName)
.then(() => {
this.hasValidationError = false;
})

View File

@@ -5,6 +5,7 @@ export class FolderSettingsCtrl {
folderPageLoader: FolderPageLoader;
navModel: any;
folderId: number;
uid: string;
canSave = false;
dashboard: any;
meta: any;
@@ -13,14 +14,18 @@ export class FolderSettingsCtrl {
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams, private $location) {
if (this.$routeParams.folderId && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
this.folderPageLoader = new FolderPageLoader(this.backendSrv, this.$routeParams);
this.folderPageLoader.load(this, this.folderId, 'manage-folder-settings').then(result => {
this.dashboard = result.dashboard;
this.meta = result.meta;
this.canSave = result.meta.canSave;
this.folderPageLoader = new FolderPageLoader(this.backendSrv);
this.folderPageLoader.load(this, this.uid, 'manage-folder-settings').then(folder => {
if ($location.path() !== folder.meta.url) {
$location.path(`${folder.meta.url}/settings`).replace();
}
this.dashboard = folder.dashboard;
this.meta = folder.meta;
this.canSave = folder.meta.canSave;
this.title = this.dashboard.title;
});
}
@@ -36,11 +41,10 @@ export class FolderSettingsCtrl {
this.dashboard.title = this.title.trim();
return this.backendSrv
.saveDashboard(this.dashboard, { overwrite: false })
.updateDashboardFolder(this.dashboard, { overwrite: false })
.then(result => {
var folderUrl = this.folderPageLoader.createFolderUrl(this.folderId, result.slug);
if (folderUrl !== this.$location.path()) {
this.$location.url(folderUrl + '/settings');
if (result.url !== this.$location.path()) {
this.$location.url(result.url + '/settings');
}
appEvents.emit('dashboard-saved');
@@ -65,7 +69,7 @@ export class FolderSettingsCtrl {
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
return this.backendSrv.deleteDashboard(this.meta.slug).then(() => {
return this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]);
this.$location.url('dashboards');
});
@@ -84,7 +88,7 @@ export class FolderSettingsCtrl {
yesText: 'Save & Overwrite',
icon: 'fa-warning',
onConfirm: () => {
this.backendSrv.saveDashboard(this.dashboard, { overwrite: true });
this.backendSrv.updateDashboardFolder(this.dashboard, { overwrite: true });
},
});
}

View File

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

View File

@@ -46,6 +46,7 @@ export class SaveDashboardAsModalCtrl {
var dashboard = this.dashboardSrv.getCurrent();
this.clone = dashboard.getSaveModelClone();
this.clone.id = null;
this.clone.uid = '';
this.clone.title += ' Copy';
this.clone.editable = true;
this.clone.hideControls = false;

View File

@@ -96,13 +96,14 @@
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
<dashboard-permissions ng-if="ctrl.dashboard"
<dashboard-permissions ng-if="ctrl.dashboard && !ctrl.hasUnsavedFolderChange"
dashboardId="ctrl.dashboard.id"
backendSrv="ctrl.backendSrv"
folderTitle="ctrl.dashboard.meta.folderTitle"
folderSlug="ctrl.dashboard.meta.folderSlug"
folderId="ctrl.dashboard.meta.folderId"
folder="ctrl.getFolder()"
/>
<div ng-if="ctrl.hasUnsavedFolderChange">
<h5>You have changed folder, please save to view permissions.</h5>
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">

View File

@@ -14,6 +14,7 @@ export class SettingsCtrl {
canSave: boolean;
canDelete: boolean;
sections: any[];
hasUnsavedFolderChange: boolean;
/** @ngInject */
constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
@@ -38,6 +39,7 @@ export class SettingsCtrl {
this.$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.$rootScope.onAppEvent('dashboard-saved', this.onPostSave.bind(this), $scope);
}
buildSectionList() {
@@ -135,6 +137,10 @@ export class SettingsCtrl {
this.dashboardSrv.saveDashboard();
}
onPostSave() {
this.hasUnsavedFolderChange = false;
}
hideSettings() {
var urlParams = this.$location.search();
delete urlParams.editview;
@@ -186,7 +192,7 @@ export class SettingsCtrl {
}
deleteDashboardConfirmed() {
this.backendSrv.deleteDashboard(this.dashboard.meta.slug).then(() => {
this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
appEvents.emit('alert-success', ['Dashboard Deleted', this.dashboard.title + ' has been deleted']);
this.$location.url('/');
});
@@ -195,7 +201,15 @@ export class SettingsCtrl {
onFolderChange(folder) {
this.dashboard.meta.folderId = folder.id;
this.dashboard.meta.folderTitle = folder.title;
this.dashboard.meta.folderSlug = folder.slug;
this.hasUnsavedFolderChange = true;
}
getFolder() {
return {
id: this.dashboard.meta.folderId,
title: this.dashboard.meta.folderTitle,
url: this.dashboard.meta.folderUrl,
};
}
}

View File

@@ -74,6 +74,7 @@ export class ShareModalCtrl {
$scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params);
var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
delete params.fullscreen;
delete params.edit;
soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
@@ -84,6 +85,7 @@ export class ShareModalCtrl {
config.appSubUrl + '/dashboard-solo/',
config.appSubUrl + '/render/dashboard-solo/'
);
$scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
$scope.imageUrl += '&width=1000';
$scope.imageUrl += '&height=500';
$scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format('Z'));

View File

@@ -19,7 +19,7 @@ describe('DashboardImportCtrl', function() {
};
validationSrv = {
validateNewDashboardOrFolderName: jest.fn().mockReturnValue(Promise.resolve()),
validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()),
};
ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {});

View File

@@ -629,4 +629,23 @@ describe('given dashboard with row and panel repeat', () => {
region: { text: 'reg2', value: 'reg2' },
});
});
it('should repeat panels when row is expanding', function() {
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels.length).toBe(6);
// toggle row
dashboard.toggleRow(dashboard.panels[0]);
dashboard.toggleRow(dashboard.panels[1]);
expect(dashboard.panels.length).toBe(2);
// change variable
dashboard.templating.list[1].current.value = ['se1', 'se2', 'se3'];
// toggle row back
dashboard.toggleRow(dashboard.panels[1]);
expect(dashboard.panels.length).toBe(4);
});
});

View File

@@ -43,12 +43,23 @@ describe('ShareModalCtrl', function() {
});
it('should generate render url', function() {
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/db/my-dash';
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/d/abcdefghi/my-dash';
ctx.scope.panel = { id: 22 };
ctx.scope.init();
var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash';
var base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(ctx.scope.imageUrl).to.contain(base + params);
});
it('should generate render url for scripted dashboard', function() {
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/script/my-dash.js';
ctx.scope.panel = { id: 22 };
ctx.scope.init();
var base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(ctx.scope.imageUrl).to.contain(base + params);
});

View File

@@ -1,13 +1,27 @@
import coreModule from 'app/core/core_module';
const hitTypes = {
FOLDER: 'dash-folder',
DASHBOARD: 'dash-db',
};
export class ValidationSrv {
rootName = 'root';
rootName = 'general';
/** @ngInject */
constructor(private $q, private backendSrv) {}
validateNewDashboardOrFolderName(name) {
validateNewDashboardName(folderId, name) {
return this.validate(folderId, name, 'A dashboard in this folder with the same name already exists');
}
validateNewFolderName(name) {
return this.validate(0, name, 'A folder or dashboard in the general folder with the same name already exists');
}
private validate(folderId, name, existingErrorMessage) {
name = (name || '').trim();
const nameLowerCased = name.toLowerCase();
if (name.length === 0) {
return this.$q.reject({
@@ -16,21 +30,35 @@ export class ValidationSrv {
});
}
if (name.toLowerCase() === this.rootName) {
if (folderId === 0 && nameLowerCased === this.rootName) {
return this.$q.reject({
type: 'EXISTING',
message: 'A folder or dashboard with the same name already exists',
message: 'This is a reserved name and cannot be used for a folder.',
});
}
let deferred = this.$q.defer();
this.backendSrv.search({ query: name }).then(res => {
for (let hit of res) {
if (name.toLowerCase() === hit.title.toLowerCase()) {
const promises = [];
promises.push(this.backendSrv.search({ type: hitTypes.FOLDER, folderIds: [folderId], query: name }));
promises.push(this.backendSrv.search({ type: hitTypes.DASHBOARD, folderIds: [folderId], query: name }));
this.$q.all(promises).then(res => {
let hits = [];
if (res.length > 0 && res[0].length > 0) {
hits = res[0];
}
if (res.length > 1 && res[1].length > 0) {
hits = hits.concat(res[1]);
}
for (let hit of hits) {
if (nameLowerCased === hit.title.toLowerCase()) {
deferred.reject({
type: 'EXISTING',
message: 'A folder or dashboard with the same name already exists',
message: existingErrorMessage,
});
break;
}

View File

@@ -1,19 +1,33 @@
import angular from 'angular';
import locationUtil from 'app/core/utils/location_util';
import appEvents from 'app/core/app_events';
export class SoloPanelCtrl {
/** @ngInject */
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv) {
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
var panelId;
$scope.init = function() {
contextSrv.sidemenu = false;
appEvents.emit('toggle-sidemenu');
var params = $location.search();
panelId = parseInt(params.panelId);
$scope.onAppEvent('dashboard-initialized', $scope.initPanelScope);
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
// if no uid, redirect to new route based on slug
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
$location.path(url).replace();
}
});
return;
}
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
});

View File

@@ -12,7 +12,7 @@
<div class="alert-rule-item__body">
<div class="alert-rule-item__header">
<p class="alert-rule-item__name">
<a href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
<a href="{{alert.url}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
{{alert.name}}
</a>
</p>

View File

@@ -4,7 +4,7 @@
{{group.header}}
</h6>
<div class="dashlist-item" ng-repeat="dash in group.list">
<a class="dashlist-link dashlist-link-{{dash.type}}" href="dashboard/{{dash.uri}}">
<a class="dashlist-link dashlist-link-{{dash.type}}" href="{{dash.url}}">
<span class="dashlist-title">
{{dash.title}}
</span>

View File

@@ -1,14 +1,15 @@
import coreModule from 'app/core/core_module';
import locationUtil from 'app/core/utils/location_util';
export class LoadDashboardCtrl {
/** @ngInject */
constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location) {
constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location, $browser) {
$scope.appEvent('dashboard-fetch-start');
if (!$routeParams.slug) {
if (!$routeParams.uid && !$routeParams.slug) {
backendSrv.get('/api/dashboards/home').then(function(homeDash) {
if (homeDash.redirectUri) {
$location.path('dashboard/' + homeDash.redirectUri);
$location.path(homeDash.redirectUri);
} else {
var meta = homeDash.meta;
meta.canSave = meta.canShare = meta.canStar = false;
@@ -18,7 +19,26 @@ export class LoadDashboardCtrl {
return;
}
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
// if no uid, redirect to new route based on slug
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url);
$location.path(url).replace();
}
});
return;
}
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
if (result.meta.url) {
const url = locationUtil.stripBaseFromUrl(result.meta.url);
if (url !== $location.path()) {
$location.path(url).replace();
}
}
if ($routeParams.keepRows) {
result.meta.keepRows = true;
}

View File

@@ -16,12 +16,24 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/d/:uid/:slug', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/dashboard/:type/:slug', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/d-solo/:uid/:slug', {
templateUrl: 'public/app/features/panel/partials/soloPanel.html',
controller: 'SoloPanelCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/dashboard-solo/:type/:slug', {
templateUrl: 'public/app/features/panel/partials/soloPanel.html',
controller: 'SoloPanelCtrl',
@@ -69,19 +81,19 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controller: 'CreateFolderCtrl',
controllerAs: 'ctrl',
})
.when('/dashboards/folder/:folderId/:slug/permissions', {
.when('/dashboards/f/:uid/:slug/permissions', {
template: '<react-container />',
resolve: {
component: () => FolderPermissions,
},
})
.when('/dashboards/folder/:folderId/:slug/settings', {
.when('/dashboards/f/:uid/:slug/settings', {
template: '<react-container />',
resolve: {
component: () => FolderSettings,
},
})
.when('/dashboards/folder/:folderId/:slug', {
.when('/dashboards/f/:uid/:slug', {
templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html',
controller: 'FolderDashboardsCtrl',
controllerAs: 'ctrl',

View File

@@ -14,7 +14,7 @@ function getRule(name, state, info) {
.format(),
evalData: {},
executionError: '',
dashboardUri: 'db/mygool',
url: 'db/mygool',
stateText: state,
stateIcon: 'fa',
stateClass: 'asd',

View File

@@ -13,7 +13,7 @@ export const AlertRule = types
stateClass: types.string,
stateAge: types.string,
info: types.optional(types.string, ''),
dashboardUri: types.string,
url: types.string,
canEdit: types.boolean,
})
.views(self => ({

View File

@@ -2,8 +2,8 @@ import { types, getEnv, flow } from 'mobx-state-tree';
export const Folder = types.model('Folder', {
id: types.identifier(types.number),
slug: types.string,
title: types.string,
url: types.string,
canSave: types.boolean,
hasChanged: types.boolean,
});
@@ -13,13 +13,13 @@ export const FolderStore = types
folder: types.maybe(Folder),
})
.actions(self => ({
load: flow(function* load(slug: string) {
load: flow(function* load(uid: string) {
const backendSrv = getEnv(self).backendSrv;
const res = yield backendSrv.getDashboard('db', slug);
const res = yield backendSrv.getDashboardByUid(uid);
self.folder = Folder.create({
id: res.dashboard.id,
title: res.dashboard.title,
slug: res.meta.slug,
url: res.meta.url,
canSave: res.meta.canSave,
hasChanged: false,
});
@@ -35,14 +35,15 @@ export const FolderStore = types
const backendSrv = getEnv(self).backendSrv;
dashboard.title = self.folder.title.trim();
const res = yield backendSrv.saveDashboard(dashboard, options);
self.folder.slug = res.slug;
return `dashboards/folder/${self.folder.id}/${res.slug}/settings`;
const res = yield backendSrv.saveFolder(dashboard, options);
self.folder.url = res.url;
return `${self.folder.url}/settings`;
}),
deleteFolder: flow(function* deleteFolder() {
const backendSrv = getEnv(self).backendSrv;
return backendSrv.deleteDashboard(self.folder.slug);
return backendSrv.deleteDashboard(self.folder.url);
}),
}));

View File

@@ -3,12 +3,12 @@ import { NavStore } from './NavStore';
describe('NavStore', () => {
const folderId = 1;
const folderTitle = 'Folder Name';
const folderSlug = 'folder-name';
const folderUrl = '/dashboards/f/uid/folder-name';
const canAdmin = true;
const folder = {
id: folderId,
slug: folderSlug,
url: folderUrl,
title: folderTitle,
canAdmin: canAdmin,
};
@@ -33,9 +33,9 @@ describe('NavStore', () => {
it('Should set correct urls for each tab', () => {
expect(store.main.children.length).toBe(3);
expect(store.main.children[0].url).toBe(`dashboards/folder/${folderId}/${folderSlug}`);
expect(store.main.children[1].url).toBe(`dashboards/folder/${folderId}/${folderSlug}/permissions`);
expect(store.main.children[2].url).toBe(`dashboards/folder/${folderId}/${folderSlug}/settings`);
expect(store.main.children[0].url).toBe(folderUrl);
expect(store.main.children[1].url).toBe(`${folderUrl}/permissions`);
expect(store.main.children[2].url).toBe(`${folderUrl}/settings`);
});
it('Should set active tab', () => {

View File

@@ -41,8 +41,6 @@ export const NavStore = types
},
initFolderNav(folder: any, activeChildId: string) {
const folderUrl = createFolderUrl(folder.id, folder.slug);
let main = {
icon: 'fa fa-folder-open',
id: 'manage-folder',
@@ -56,21 +54,21 @@ export const NavStore = types
icon: 'fa fa-fw fa-th-large',
id: 'manage-folder-dashboards',
text: 'Dashboards',
url: folderUrl,
url: folder.url,
},
{
active: activeChildId === 'manage-folder-permissions',
icon: 'fa fa-fw fa-lock',
id: 'manage-folder-permissions',
text: 'Permissions',
url: folderUrl + '/permissions',
url: `${folder.url}/permissions`,
},
{
active: activeChildId === 'manage-folder-settings',
icon: 'fa fa-fw fa-cog',
id: 'manage-folder-settings',
text: 'Settings',
url: folderUrl + '/settings',
url: `${folder.url}/settings`,
},
],
};
@@ -118,7 +116,3 @@ export const NavStore = types
self.main = NavItem.create(main);
},
}));
function createFolderUrl(folderId: number, slug: string) {
return `dashboards/folder/${folderId}/${slug}`;
}

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