diff --git a/CHANGELOG.md b/CHANGELOG.md index d7dd13dba98..2dacbb716e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Gopkg.lock b/Gopkg.lock index 4d5f6ca0a46..8d82b29d622 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -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 diff --git a/Gopkg.toml b/Gopkg.toml index 144dcc1e4af..22c56d29c71 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -193,3 +193,7 @@ ignored = [ non-go = true go-tests = true unused-packages = true + +[[constraint]] + branch = "master" + name = "github.com/teris-io/shortid" diff --git a/docs/sources/administration/permissions.md b/docs/sources/administration/permissions.md index daecb1807e8..8f4a73163df 100644 --- a/docs/sources/administration/permissions.md +++ b/docs/sources/administration/permissions.md @@ -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. diff --git a/docs/sources/guides/whats-new-in-v5.md b/docs/sources/guides/whats-new-in-v5.md index 89377c5ccf5..0cda207d650 100644 --- a/docs/sources/guides/whats-new-in-v5.md +++ b/docs/sources/guides/whats-new-in-v5.md @@ -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.
@@ -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.
@@ -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) diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index 221552414e9..3860ae490b1 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -62,7 +62,7 @@ Content-Type: application/json } "newStateDate": "2016-12-25", "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "url": "http://grafana.com/dashboard/db/sensors" } ] ``` @@ -94,7 +94,7 @@ Content-Type: application/json "state": "alerting", "newStateDate": "2016-12-25", "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "url": "http://grafana.com/dashboard/db/sensors" } ``` @@ -196,7 +196,7 @@ Content-Type: application/json ## Create alert notification -You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. +You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. `POST /api/alert-notifications` @@ -294,4 +294,4 @@ Content-Type: application/json { "message": "Notification deleted" } -``` \ No newline at end of file +``` diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 364b55b0cfc..468a812c709 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -90,7 +90,7 @@ Content-Type: application/json ## Get a single data source by Name -`GET /api/datasources/name/:name` +`GET /api/datasources/:name` **Example Request**: diff --git a/docs/sources/http_api/index.md b/docs/sources/http_api/index.md index 9c5d0e7d49a..cbfe004b14c 100644 --- a/docs/sources/http_api/index.md +++ b/docs/sources/http_api/index.md @@ -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" >}}) diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index d8c4edbf4d3..1e0d38645e8 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -671,31 +671,6 @@ session provider you have configured. - **memcache:** ex: 127.0.0.1:11211 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana` -If you use MySQL or Postgres as the session store you need to create the -session table manually. - -Mysql Example: - -```bash -CREATE TABLE `session` ( - `key` CHAR(16) NOT NULL, - `data` BLOB, - `expiry` INT(11) UNSIGNED NOT NULL, - PRIMARY KEY (`key`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8; -``` - -Postgres Example: - -```bash -CREATE TABLE session ( - key CHAR(16) NOT NULL, - data BYTEA, - expiry INTEGER NOT NULL, - PRIMARY KEY (key) -); -``` - Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default). ### cookie_name diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index 0149f06e1aa..09a005ba714 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -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] ] } ] diff --git a/package.json b/package.json index 80fa9a699f8..acb992a0936 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 42ea091ef10..16f5f7ceb6f 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -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 } } } diff --git a/pkg/api/api.go b/pkg/api/api.go index 086d7345483..752af7602f5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 206b7f890b5..d7676899eb2 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -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) diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go index 6eb11047723..b5d912d25f1 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_acl.go @@ -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) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index c83479242fc..be15fb41a66 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -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 +} diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index c32edb6e51c..d30f2697f3f 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -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"` } diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index e101b0ff623..e4c66aebbda 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -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 { diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index 25d77557342..313f7892707 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -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() { diff --git a/pkg/log/log.go b/pkg/log/log.go index 88b90f0cf8e..0e6874e1b4b 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -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) } diff --git a/pkg/log/log_writer.go b/pkg/log/log_writer.go new file mode 100644 index 00000000000..2ff401a7f0a --- /dev/null +++ b/pkg/log/log_writer.go @@ -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 +} diff --git a/pkg/log/log_writer_test.go b/pkg/log/log_writer_test.go new file mode 100644 index 00000000000..4537b4d6100 --- /dev/null +++ b/pkg/log/log_writer_test.go @@ -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") + }) + }) +} diff --git a/pkg/middleware/dashboard_redirect.go b/pkg/middleware/dashboard_redirect.go new file mode 100644 index 00000000000..4a3812fb8a2 --- /dev/null +++ b/pkg/middleware/dashboard_redirect.go @@ -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 + } + } + } +} diff --git a/pkg/middleware/dashboard_redirect_test.go b/pkg/middleware/dashboard_redirect_test.go new file mode 100644 index 00000000000..0af06347ed0 --- /dev/null +++ b/pkg/middleware/dashboard_redirect_test.go @@ -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) + }) + }) + }) +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 0d9e0e5b973..ffd8e8a0af0 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -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 diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index fa7ad00de7f..933487650e3 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -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"` } // diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 69e490ece11..12216718b44 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -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 +} diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index f5663deb8ca..d598203d675 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -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 } } diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 47c9e0c590e..af9ba52a52a 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -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 { diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go index cf510ed8462..6dea975d9fe 100644 --- a/pkg/services/search/models.go +++ b/pkg/services/search/models.go @@ -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 diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 81dab375188..af87c324216 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -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 +} diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 9027f74f33a..829182a8195 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -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 diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go new file mode 100644 index 00000000000..10a55f9b22b --- /dev/null +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -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) + }) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index b5d21b37567..97ab5472d94 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -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" diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index 6ed37cd6904..e20ac897b3d 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -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) diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 4f1602be931..bb2aea4bd51 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -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, + })) } diff --git a/pkg/services/sqlstore/search_builder.go b/pkg/services/sqlstore/search_builder.go index ddf192da5ff..627074d5453 100644 --- a/pkg/services/sqlstore/search_builder.go +++ b/pkg/services/sqlstore/search_builder.go @@ -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 `) diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go new file mode 100644 index 00000000000..067f7c756ba --- /dev/null +++ b/pkg/util/shortid_generator.go @@ -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() +} diff --git a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx index f5aa07b454a..eac18a6c69d 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx @@ -23,7 +23,7 @@ describe('AlertRuleList', () => { .format(), evalData: {}, executionError: '', - dashboardUri: 'db/mygool', + url: 'd/ufkcofof/my-goal', canEdit: true, }, ]) diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx index d2712706154..6fb6e3b7d8f 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.tsx @@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component { '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 (
  • diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap index da5fa5f12c4..0914f050a0f 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap +++ b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap @@ -21,7 +21,7 @@ exports[`AlertRuleList should render 1 rule 1`] = ` className="alert-rule-item__name" > { 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 {
    -
    +

    Folder Permissions

    +
    +
    - + + +
    diff --git a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx index 3355b657f03..bf7b35ed05d 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx @@ -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, }, }) diff --git a/public/app/containers/ManageDashboards/FolderSettings.tsx b/public/app/containers/ManageDashboards/FolderSettings.tsx index ef3377622df..a6349764a14 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.tsx @@ -20,10 +20,12 @@ export class FolderSettings extends React.Component { 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 { folder .saveFolder(this.dashboard, { overwrite: false }) .then(newUrl => { - view.updatePathAndQuery(newUrl, '', ''); + view.updatePathAndQuery(newUrl, {}, {}); appEvents.emit('dashboard-saved'); appEvents.emit('alert-success', ['Folder saved']); diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index bc3fb08d822..ace0eb00b07 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -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']); } diff --git a/public/app/core/components/Animations/SlideDown.tsx b/public/app/core/components/Animations/SlideDown.tsx new file mode 100644 index 00000000000..4d515f98f16 --- /dev/null +++ b/public/app/core/components/Animations/SlideDown.tsx @@ -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 + +
    +
    Add Permission For
    +
    +
    +
    + +
    +
    + + {newItem.type === 'User' ? ( +
    + +
    + ) : null} + + {newItem.type === 'Group' ? ( +
    + +
    + ) : null} + +
    + +
    + +
    + +
    +
    +
    + {permissions.error ? ( +
    + + + {permissions.error} + +
    + ) : null} +
    + ); + } +} + +export default AddPermissions; diff --git a/public/app/core/components/Permissions/DashboardPermissions.tsx b/public/app/core/components/Permissions/DashboardPermissions.tsx index 2636b0d4db4..12339cc7c34 100644 --- a/public/app/core/components/Permissions/DashboardPermissions.tsx +++ b/public/app/core/components/Permissions/DashboardPermissions.tsx @@ -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 { 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 (
    -

    Permissions

    - - - +
    +

    Permissions

    + + + +
    + +
    + + +
    diff --git a/public/app/core/components/Permissions/FolderInfo.ts b/public/app/core/components/Permissions/FolderInfo.ts index 67ebb753df0..d4a6020bb71 100644 --- a/public/app/core/components/Permissions/FolderInfo.ts +++ b/public/app/core/components/Permissions/FolderInfo.ts @@ -1,5 +1,5 @@ export interface FolderInfo { - title: string; id: number; - slug: string; + title: string; + url: string; } diff --git a/public/app/core/components/Permissions/Permissions.jest.tsx b/public/app/core/components/Permissions/Permissions.jest.tsx deleted file mode 100644 index 0a608ee5842..00000000000 --- a/public/app/core/components/Permissions/Permissions.jest.tsx +++ /dev/null @@ -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(); - 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'); - }); - }); - }); -}); diff --git a/public/app/core/components/Permissions/Permissions.tsx b/public/app/core/components/Permissions/Permissions.tsx index 647a9060b0d..0a0572ed86e 100644 --- a/public/app/core/components/Permissions/Permissions.tsx +++ b/public/app/core/components/Permissions/Permissions.tsx @@ -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 { 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 { 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 (
    @@ -109,50 +84,6 @@ class Permissions extends Component { fetching={permissions.fetching} folderInfo={folderInfo} /> -
    -
    -
    Add Permission For
    -
    -
    -
    - -
    -
    - - {permissions.newType === 'User' ? ( -
    - -
    - ) : null} - - {permissions.newType === 'Group' ? ( -
    - -
    - ) : null} -
    -
    - {permissions.error ? ( -
    - - - {permissions.error} - -
    - ) : null} -
    ); } diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx index 291ee20e157..3140b8fcc0c 100644 --- a/public/app/core/components/Permissions/PermissionsListItem.tsx +++ b/public/app/core/components/Permissions/PermissionsListItem.tsx @@ -30,7 +30,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde folderInfo && ( Inherited from folder{' '} -
    + {folderInfo.title} {' '} diff --git a/public/app/core/components/Picker/TeamPicker.jest.tsx b/public/app/core/components/Picker/TeamPicker.jest.tsx new file mode 100644 index 00000000000..20b7620e0ac --- /dev/null +++ b/public/app/core/components/Picker/TeamPicker.jest.tsx @@ -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().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/Picker/TeamPicker.tsx b/public/app/core/components/Picker/TeamPicker.tsx index 4b5a049c0ff..2dfff1850dd 100644 --- a/public/app/core/components/Picker/TeamPicker.tsx +++ b/public/app/core/components/Picker/TeamPicker.tsx @@ -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 { render() { const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async; - const { isLoading, handlePicked } = this.props; + const { isLoading, handlePicked, value, className } = this.props; return (
    @@ -66,10 +68,13 @@ class TeamPicker extends Component { 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} />
    ); diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index 141371813a2..5c36505aeaa 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -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 { render() { const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async; - const { isLoading, handlePicked } = this.props; - + const { isLoading, handlePicked, value, className } = this.props; return (
    { 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} />
    ); diff --git a/public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap b/public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap new file mode 100644 index 00000000000..67232d0ea5b --- /dev/null +++ b/public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TeamPicker renders correctly 1`] = ` +
    +
    +
    + +
    + Loading... +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +`; diff --git a/public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap b/public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap index a1563ba8bc3..3262dc10efe 100644 --- a/public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap +++ b/public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap @@ -5,7 +5,7 @@ exports[`UserPicker renders correctly 1`] = ` className="user-picker" >
    void; + value?: string; + className?: string; } export default function withPicker(WrappedComponent) { diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 31b6f5b8096..70a1bda3e8b 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -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; diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index eba3099e348..d0f905f5f16 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -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: '=', }, }; } diff --git a/public/app/core/directives/dash_class.js b/public/app/core/directives/dash_class.js index 9df53bdbd48..4a139272632 100644 --- a/public/app/core/directives/dash_class.js +++ b/public/app/core/directives/dash_class.js @@ -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)); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 3204db0ce5d..0a8b305ea53 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -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) { diff --git a/public/app/core/services/bridge_srv.ts b/public/app/core/services/bridge_srv.ts index f0166d89e9d..4a5649a6c52 100644 --- a/public/app/core/services/bridge_srv.ts +++ b/public/app/core/services/bridge_srv.ts @@ -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 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; diff --git a/public/app/core/services/search_srv.ts b/public/app/core/services/search_srv.ts index a909b4af09f..9f32e21f3f6 100644 --- a/public/app/core/services/search_srv.ts +++ b/public/app/core/services/search_srv.ts @@ -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); }); } diff --git a/public/app/core/specs/bridge_srv.jest.ts b/public/app/core/specs/bridge_srv.jest.ts deleted file mode 100644 index 2e32f9bd600..00000000000 --- a/public/app/core/specs/bridge_srv.jest.ts +++ /dev/null @@ -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/'); - }); - }); -}); diff --git a/public/app/core/specs/location_util.jest.ts b/public/app/core/specs/location_util.jest.ts new file mode 100644 index 00000000000..c109ab7315a --- /dev/null +++ b/public/app/core/specs/location_util.jest.ts @@ -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/'); + }); + }); +}); diff --git a/public/app/core/specs/manage_dashboards.jest.ts b/public/app/core/specs/manage_dashboards.jest.ts index 03aee78571f..4b51e6848d0 100644 --- a/public/app/core/specs/manage_dashboards.jest.ts +++ b/public/app/core/specs/manage_dashboards.jest.ts @@ -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, }, ]; diff --git a/public/app/core/specs/search.jest.ts b/public/app/core/specs/search.jest.ts index 2457d71a48d..2c1172bcf55 100644 --- a/public/app/core/specs/search.jest.ts +++ b/public/app/core/specs/search.jest.ts @@ -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, diff --git a/public/app/core/specs/search_srv.jest.ts b/public/app/core/specs/search_srv.jest.ts index 444939d1f4a..550d11bbf9e 100644 --- a/public/app/core/specs/search_srv.jest.ts +++ b/public/app/core/specs/search_srv.jest.ts @@ -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'); }); }); diff --git a/public/app/core/utils/location_util.ts b/public/app/core/utils/location_util.ts new file mode 100644 index 00000000000..f8d6aa4ee5f --- /dev/null +++ b/public/app/core/utils/location_util.ts @@ -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, +}; diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/dashboard/create_folder_ctrl.ts index 414054232c2..0c582164e2b 100644 --- a/public/app/features/dashboard/create_folder_ctrl.ts +++ b/public/app/features/dashboard/create_folder_ctrl.ts @@ -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; }) diff --git a/public/app/features/dashboard/dashboard_import_ctrl.ts b/public/app/features/dashboard/dashboard_import_ctrl.ts index 86fe12eab8a..c3af0a51a19 100644 --- a/public/app/features/dashboard/dashboard_import_ctrl.ts +++ b/public/app/features/dashboard/dashboard_import_ctrl.ts @@ -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; }) diff --git a/public/app/features/dashboard/dashboard_loader_srv.ts b/public/app/features/dashboard/dashboard_loader_srv.ts index e4def4385e1..9e458c4e4bb 100644 --- a/public/app/features/dashboard/dashboard_loader_srv.ts +++ b/public/app/features/dashboard/dashboard_loader_srv.ts @@ -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']); diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 4086edc100a..055b59065ae 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -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); } } diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index ee7ba3d6aac..08ab70053f6 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -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); diff --git a/public/app/features/dashboard/folder_dashboards_ctrl.ts b/public/app/features/dashboard/folder_dashboards_ctrl.ts index 943ddf8892f..8ee942445ae 100644 --- a/public/app/features/dashboard/folder_dashboards_ctrl.ts +++ b/public/app/features/dashboard/folder_dashboards_ctrl.ts @@ -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(); + } }); } } diff --git a/public/app/features/dashboard/folder_page_loader.ts b/public/app/features/dashboard/folder_page_loader.ts index 9218f723559..81d10068361 100755 --- a/public/app/features/dashboard/folder_page_loader.ts +++ b/public/app/features/dashboard/folder_page_loader.ts @@ -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}`; - } } diff --git a/public/app/features/dashboard/folder_permissions_ctrl.ts b/public/app/features/dashboard/folder_permissions_ctrl.ts index 63239c1aadb..4ab91acb3d9 100644 --- a/public/app/features/dashboard/folder_permissions_ctrl.ts +++ b/public/app/features/dashboard/folder_permissions_ctrl.ts @@ -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; + }); } } } diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts index 74b13c5c2a5..56284a877c5 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -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; }) diff --git a/public/app/features/dashboard/folder_settings_ctrl.ts b/public/app/features/dashboard/folder_settings_ctrl.ts index 7bb3d469a86..004ba2efa9f 100644 --- a/public/app/features/dashboard/folder_settings_ctrl.ts +++ b/public/app/features/dashboard/folder_settings_ctrl.ts @@ -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 }); }, }); } diff --git a/public/app/features/dashboard/partials/folder_dashboards.html b/public/app/features/dashboard/partials/folder_dashboards.html index a03c53233f5..1082bee402e 100644 --- a/public/app/features/dashboard/partials/folder_dashboards.html +++ b/public/app/features/dashboard/partials/folder_dashboards.html @@ -1,5 +1,5 @@
    - +
    diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/save_as_modal.ts index 04b029cbb4c..15a3ce5dba7 100644 --- a/public/app/features/dashboard/save_as_modal.ts +++ b/public/app/features/dashboard/save_as_modal.ts @@ -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; diff --git a/public/app/features/dashboard/settings/settings.html b/public/app/features/dashboard/settings/settings.html index 47c3bae2ef6..5103fab8b75 100644 --- a/public/app/features/dashboard/settings/settings.html +++ b/public/app/features/dashboard/settings/settings.html @@ -96,13 +96,14 @@
    - +
    +
    You have changed folder, please save to view permissions.
    +
    diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts index 9fbe04e9685..e9d5c6180be 100755 --- a/public/app/features/dashboard/settings/settings.ts +++ b/public/app/features/dashboard/settings/settings.ts @@ -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, + }; } } diff --git a/public/app/features/dashboard/shareModalCtrl.ts b/public/app/features/dashboard/shareModalCtrl.ts index 508658cd53b..8e30eaef91e 100644 --- a/public/app/features/dashboard/shareModalCtrl.ts +++ b/public/app/features/dashboard/shareModalCtrl.ts @@ -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')); diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts index 739485b235d..3d311b8b4f8 100644 --- a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts @@ -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, {}, {}, {}); diff --git a/public/app/features/dashboard/specs/repeat.jest.ts b/public/app/features/dashboard/specs/repeat.jest.ts index 93555625b95..868db3b7246 100644 --- a/public/app/features/dashboard/specs/repeat.jest.ts +++ b/public/app/features/dashboard/specs/repeat.jest.ts @@ -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); + }); }); diff --git a/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts b/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts index 8bf8f53ed46..fc70a54a41c 100644 --- a/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts +++ b/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts @@ -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); }); diff --git a/public/app/features/dashboard/validation_srv.ts b/public/app/features/dashboard/validation_srv.ts index e70bb0c426d..817be7ca0e3 100644 --- a/public/app/features/dashboard/validation_srv.ts +++ b/public/app/features/dashboard/validation_srv.ts @@ -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; } diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index f141f89eb80..323a88ddaee 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -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); }); diff --git a/public/app/plugins/panel/alertlist/module.html b/public/app/plugins/panel/alertlist/module.html index e795c4fa59f..b8055b5fc7f 100644 --- a/public/app/plugins/panel/alertlist/module.html +++ b/public/app/plugins/panel/alertlist/module.html @@ -12,7 +12,7 @@

    - + {{alert.name}}

    diff --git a/public/app/plugins/panel/dashlist/module.html b/public/app/plugins/panel/dashlist/module.html index 7f71811ac08..8fa3e7ef71f 100644 --- a/public/app/plugins/panel/dashlist/module.html +++ b/public/app/plugins/panel/dashlist/module.html @@ -4,7 +4,7 @@ {{group.header}}
    - + {{dash.title}} diff --git a/public/app/routes/dashboard_loaders.ts b/public/app/routes/dashboard_loaders.ts index f4f0b36ac72..a9ccd60ee4f 100644 --- a/public/app/routes/dashboard_loaders.ts +++ b/public/app/routes/dashboard_loaders.ts @@ -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; } diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index e533f68606f..cab45b5aff3 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -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: '', resolve: { component: () => FolderPermissions, }, }) - .when('/dashboards/folder/:folderId/:slug/settings', { + .when('/dashboards/f/:uid/:slug/settings', { template: '', 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', diff --git a/public/app/stores/AlertListStore/AlertListStore.jest.ts b/public/app/stores/AlertListStore/AlertListStore.jest.ts index 9092ed26df2..f0ab24b6cfc 100644 --- a/public/app/stores/AlertListStore/AlertListStore.jest.ts +++ b/public/app/stores/AlertListStore/AlertListStore.jest.ts @@ -14,7 +14,7 @@ function getRule(name, state, info) { .format(), evalData: {}, executionError: '', - dashboardUri: 'db/mygool', + url: 'db/mygool', stateText: state, stateIcon: 'fa', stateClass: 'asd', diff --git a/public/app/stores/AlertListStore/AlertRule.ts b/public/app/stores/AlertListStore/AlertRule.ts index 2933daeb7c5..00881650949 100644 --- a/public/app/stores/AlertListStore/AlertRule.ts +++ b/public/app/stores/AlertListStore/AlertRule.ts @@ -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 => ({ diff --git a/public/app/stores/FolderStore/FolderStore.ts b/public/app/stores/FolderStore/FolderStore.ts index d0216e32ad7..6f14e7221f8 100644 --- a/public/app/stores/FolderStore/FolderStore.ts +++ b/public/app/stores/FolderStore/FolderStore.ts @@ -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); }), })); diff --git a/public/app/stores/NavStore/NavStore.jest.ts b/public/app/stores/NavStore/NavStore.jest.ts index b1ad820910d..43d4496c858 100644 --- a/public/app/stores/NavStore/NavStore.jest.ts +++ b/public/app/stores/NavStore/NavStore.jest.ts @@ -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', () => { diff --git a/public/app/stores/NavStore/NavStore.ts b/public/app/stores/NavStore/NavStore.ts index d04c5bf0259..86348c00487 100644 --- a/public/app/stores/NavStore/NavStore.ts +++ b/public/app/stores/NavStore/NavStore.ts @@ -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}`; -} diff --git a/public/app/stores/PermissionsStore/PermissionsStore.jest.ts b/public/app/stores/PermissionsStore/PermissionsStore.jest.ts index 3608e0a9017..97a9906d0e5 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.jest.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.jest.ts @@ -1,4 +1,4 @@ -import { PermissionsStore } from './PermissionsStore'; +import { PermissionsStore, aclTypeValues } from './PermissionsStore'; import { backendSrv } from 'test/mocks/common'; describe('PermissionsStore', () => { @@ -47,21 +47,6 @@ describe('PermissionsStore', () => { expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); }); - it('should save newly added permissions automatically', () => { - expect(store.items.length).toBe(3); - - const newItem = { - userId: 10, - userLogin: 'tester1', - permission: 1, - }; - store.addStoreItem(newItem); - - expect(store.items.length).toBe(4); - expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); - }); - it('should save removed permissions automatically', () => { expect(store.items.length).toBe(3); @@ -72,54 +57,48 @@ describe('PermissionsStore', () => { expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); }); + describe('when duplicate team permissions are added', () => { + beforeEach(() => { + const newItem = { + teamId: 10, + team: 'tester-team', + permission: 1, + dashboardId: 1, + }; + store.resetNewType(); + store.newItem.setTeam(newItem.teamId, newItem.team); + store.newItem.setPermission(newItem.permission); + store.addStoreItem(); + + store.newItem.setTeam(newItem.teamId, newItem.team); + store.newItem.setPermission(newItem.permission); + store.addStoreItem(); + }); + + it('should return a validation error', () => { + expect(store.items.length).toBe(4); + expect(store.error).toBe('This permission exists already.'); + expect(backendSrv.post.mock.calls.length).toBe(1); + }); + }); + describe('when duplicate user permissions are added', () => { beforeEach(() => { + expect(store.items.length).toBe(3); const newItem = { userId: 10, userLogin: 'tester1', permission: 1, dashboardId: 1, }; - store.addStoreItem(newItem); - store.addStoreItem(newItem); - }); - - it('should return a validation error', () => { - expect(store.items.length).toBe(4); - expect(store.error).toBe('This permission exists already.'); - expect(backendSrv.post.mock.calls.length).toBe(1); - }); - }); - - describe('when duplicate team permissions are added', () => { - beforeEach(() => { - const newItem = { - teamId: 1, - teamName: 'testerteam', - permission: 1, - dashboardId: 1, - }; - store.addStoreItem(newItem); - store.addStoreItem(newItem); - }); - - it('should return a validation error', () => { - expect(store.items.length).toBe(4); - expect(store.error).toBe('This permission exists already.'); - expect(backendSrv.post.mock.calls.length).toBe(1); - }); - }); - - describe('when duplicate role permissions are added', () => { - beforeEach(() => { - const newItem = { - team: 'MyTestTeam', - teamId: 1, - permission: 1, - dashboardId: 1, - }; - store.addStoreItem(newItem); - store.addStoreItem(newItem); + store.setNewType(aclTypeValues.USER.value); + store.newItem.setUser(newItem.userId, newItem.userLogin); + store.newItem.setPermission(newItem.permission); + store.addStoreItem(); + store.setNewType(aclTypeValues.USER.value); + store.newItem.setUser(newItem.userId, newItem.userLogin); + store.newItem.setPermission(newItem.permission); + store.addStoreItem(); }); it('should return a validation error', () => { @@ -131,20 +110,24 @@ describe('PermissionsStore', () => { describe('when one inherited and one not inherited team permission are added', () => { beforeEach(() => { - const teamItem = { + const overridingItemForChildDashboard = { team: 'MyTestTeam', dashboardId: 1, teamId: 1, permission: 2, }; - store.addStoreItem(teamItem); + + store.resetNewType(); + store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team); + store.newItem.setPermission(overridingItemForChildDashboard.permission); + store.addStoreItem(); }); - it('should not throw a validation error', () => { + it('should allowing overriding the inherited permission and not throw a validation error', () => { expect(store.error).toBe(null); }); - it('should add both permissions', () => { + it('should add new overriding permission', () => { expect(store.items.length).toBe(4); }); }); diff --git a/public/app/stores/PermissionsStore/PermissionsStore.ts b/public/app/stores/PermissionsStore/PermissionsStore.ts index cb9d8170303..9db76c4e124 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.ts @@ -1,4 +1,4 @@ -import { types, getEnv, flow } from 'mobx-state-tree'; +import { types, getEnv, flow } from 'mobx-state-tree'; import { PermissionsStoreItem } from './PermissionsStoreItem'; const duplicateError = 'This permission exists already.'; @@ -13,15 +13,62 @@ export const permissionOptions = [ }, ]; -export const aclTypes = [ - { value: 'Group', text: 'Team' }, - { value: 'User', text: 'User' }, - { value: 'Viewer', text: 'Everyone With Viewer Role' }, - { value: 'Editor', text: 'Everyone With Editor Role' }, -]; +export const aclTypeValues = { + GROUP: { value: 'Group', text: 'Team' }, + USER: { value: 'User', text: 'User' }, + VIEWER: { value: 'Viewer', text: 'Everyone With Viewer Role' }, + EDITOR: { value: 'Editor', text: 'Everyone With Editor Role' }, +}; + +export const aclTypes = Object.keys(aclTypeValues).map(item => aclTypeValues[item]); const defaultNewType = aclTypes[0].value; +export const NewPermissionsItem = types + .model('NewPermissionsItem', { + type: types.optional( + types.enumeration(Object.keys(aclTypeValues).map(item => aclTypeValues[item].value)), + defaultNewType + ), + userId: types.maybe(types.number), + userLogin: types.maybe(types.string), + teamId: types.maybe(types.number), + team: types.maybe(types.string), + permission: types.optional(types.number, 1), + }) + .views(self => ({ + isValid: () => { + switch (self.type) { + case aclTypeValues.GROUP.value: + return self.teamId && self.team; + case aclTypeValues.USER.value: + return !!self.userId && !!self.userLogin; + case aclTypeValues.VIEWER.value: + case aclTypeValues.EDITOR.value: + return true; + default: + return false; + } + }, + })) + .actions(self => ({ + setUser(userId: number, userLogin: string) { + self.userId = userId; + self.userLogin = userLogin; + self.teamId = null; + self.team = null; + }, + setTeam(teamId: number, team: string) { + self.userId = null; + self.userLogin = null; + self.teamId = teamId; + self.team = team; + }, + setPermission(permission: number) { + self.permission = permission; + }, + })); + export const PermissionsStore = types .model('PermissionsStore', { fetching: types.boolean, @@ -31,6 +78,8 @@ export const PermissionsStore = types error: types.maybe(types.string), originalItems: types.optional(types.array(PermissionsStoreItem), []), newType: types.optional(types.string, defaultNewType), + newItem: types.maybe(NewPermissionsItem), + isAddPermissionsVisible: types.optional(types.boolean, false), isInRoot: types.maybe(types.boolean), }) .views(self => ({ @@ -38,7 +87,6 @@ export const PermissionsStore = types const dupe = self.items.find(it => { return isDuplicate(it, item); }); - if (dupe) { self.error = duplicateError; return false; @@ -47,50 +95,94 @@ export const PermissionsStore = types return true; }, })) - .actions(self => ({ - load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) { - const backendSrv = getEnv(self).backendSrv; - self.fetching = true; - self.isFolder = isFolder; - self.isInRoot = isInRoot; - self.dashboardId = dashboardId; - const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`); - const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot); - self.items = items; - self.originalItems = items; - self.fetching = false; + .actions(self => { + const resetNewType = () => { self.error = null; - }), - addStoreItem: flow(function* addStoreItem(item) { - self.error = null; - if (!self.isValid(item)) { - return undefined; - } + self.newItem = NewPermissionsItem.create(); + }; - self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot)); - return updateItems(self); - }), - removeStoreItem: flow(function* removeStoreItem(idx: number) { - self.error = null; - self.items.splice(idx, 1); - return updateItems(self); - }), - updatePermissionOnIndex: flow(function* updatePermissionOnIndex( - idx: number, - permission: number, - permissionName: string - ) { - self.error = null; - self.items[idx].updatePermission(permission, permissionName); - return updateItems(self); - }), - setNewType(newType: string) { - self.newType = newType; - }, - resetNewType() { - self.newType = defaultNewType; - }, - })); + return { + load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) { + const backendSrv = getEnv(self).backendSrv; + self.fetching = true; + self.isFolder = isFolder; + self.isInRoot = isInRoot; + self.dashboardId = dashboardId; + const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`); + const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot); + self.items = items; + self.originalItems = items; + self.fetching = false; + self.error = null; + }), + addStoreItem: flow(function* addStoreItem() { + self.error = null; + let item = { + type: self.newItem.type, + permission: self.newItem.permission, + dashboardId: self.dashboardId, + team: undefined, + teamId: undefined, + userLogin: undefined, + userId: undefined, + role: undefined, + }; + switch (self.newItem.type) { + case aclTypeValues.GROUP.value: + item.team = self.newItem.team; + item.teamId = self.newItem.teamId; + break; + case aclTypeValues.USER.value: + item.userLogin = self.newItem.userLogin; + item.userId = self.newItem.userId; + break; + case aclTypeValues.VIEWER.value: + case aclTypeValues.EDITOR.value: + item.role = self.newItem.type; + break; + default: + throw Error('Unknown type: ' + self.newItem.type); + } + + if (!self.isValid(item)) { + return undefined; + } + + self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot)); + resetNewType(); + return updateItems(self); + }), + removeStoreItem: flow(function* removeStoreItem(idx: number) { + self.error = null; + self.items.splice(idx, 1); + return updateItems(self); + }), + updatePermissionOnIndex: flow(function* updatePermissionOnIndex( + idx: number, + permission: number, + permissionName: string + ) { + self.error = null; + self.items[idx].updatePermission(permission, permissionName); + return updateItems(self); + }), + setNewType(newType: string) { + self.newItem = NewPermissionsItem.create({ type: newType }); + }, + resetNewType() { + resetNewType(); + }, + toggleAddPermissions() { + self.isAddPermissionsVisible = !self.isAddPermissionsVisible; + }, + showAddPermissions() { + self.isAddPermissionsVisible = true; + }, + hideAddPermissions() { + self.isAddPermissionsVisible = false; + }, + }; + }); const updateItems = self => { self.error = null; @@ -116,7 +208,7 @@ const updateItems = self => { items: updated, }); } catch (error) { - console.error(error); + self.error = error; } return res; diff --git a/public/img/icons_light_theme/icon_add_annotation.svg b/public/img/icons_light_theme/icon_add_annotation.svg index cb306bdd18b..1cf90df2bcb 100644 --- a/public/img/icons_light_theme/icon_add_annotation.svg +++ b/public/img/icons_light_theme/icon_add_annotation.svg @@ -4,7 +4,7 @@ width="64px" height="64px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve"> @@ -13,7 +13,7 @@ - + diff --git a/public/img/icons_light_theme/icon_add_annotation_alt.svg b/public/img/icons_light_theme/icon_add_annotation_alt.svg index 2565f188d57..990df3a9d60 100644 --- a/public/img/icons_light_theme/icon_add_annotation_alt.svg +++ b/public/img/icons_light_theme/icon_add_annotation_alt.svg @@ -3,7 +3,7 @@ @@ -14,7 +14,7 @@ - + diff --git a/public/img/icons_light_theme/icon_add_folder.svg b/public/img/icons_light_theme/icon_add_folder.svg index 81bbf349616..a127eeb4dee 100644 --- a/public/img/icons_light_theme/icon_add_folder.svg +++ b/public/img/icons_light_theme/icon_add_folder.svg @@ -3,7 +3,7 @@ diff --git a/public/img/icons_light_theme/icon_add_link.svg b/public/img/icons_light_theme/icon_add_link.svg index df31a3e99a3..cdeb4450bb4 100644 --- a/public/img/icons_light_theme/icon_add_link.svg +++ b/public/img/icons_light_theme/icon_add_link.svg @@ -4,7 +4,7 @@ width="64px" height="64px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve"> diff --git a/public/img/icons_light_theme/icon_add_notification_channel.svg b/public/img/icons_light_theme/icon_add_notification_channel.svg index 9059310dcdf..68fad3d13a5 100644 --- a/public/img/icons_light_theme/icon_add_notification_channel.svg +++ b/public/img/icons_light_theme/icon_add_notification_channel.svg @@ -4,7 +4,7 @@ width="64px" height="64px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve"> diff --git a/public/img/icons_light_theme/icon_add_panel.svg b/public/img/icons_light_theme/icon_add_panel.svg index c04c6f719e2..e5630d26bb6 100644 --- a/public/img/icons_light_theme/icon_add_panel.svg +++ b/public/img/icons_light_theme/icon_add_panel.svg @@ -3,7 +3,7 @@ diff --git a/public/img/icons_light_theme/icon_add_variable.svg b/public/img/icons_light_theme/icon_add_variable.svg index da50f124e2b..3ae26bf4d9f 100644 --- a/public/img/icons_light_theme/icon_add_variable.svg +++ b/public/img/icons_light_theme/icon_add_variable.svg @@ -3,7 +3,7 @@ diff --git a/public/img/icons_light_theme/icon_alert.svg b/public/img/icons_light_theme/icon_alert.svg index a3109c23fa1..5f8b16acaa2 100644 --- a/public/img/icons_light_theme/icon_alert.svg +++ b/public/img/icons_light_theme/icon_alert.svg @@ -3,7 +3,7 @@ diff --git a/public/img/icons_light_theme/icon_alert_alt.svg b/public/img/icons_light_theme/icon_alert_alt.svg index 0d105481a7d..542004bc4d3 100644 --- a/public/img/icons_light_theme/icon_alert_alt.svg +++ b/public/img/icons_light_theme/icon_alert_alt.svg @@ -3,7 +3,7 @@