diff --git a/.gitignore b/.gitignore index 31c89aadf6b..12e7bed3f46 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ debug.test /packaging/**/*.rpm /packaging/**/*.deb +# Ignore OSX indexing +.DS_Store + /vendor/**/*.py /vendor/**/*.xml /vendor/**/*.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba7b4a1aff..0acd4b158c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,27 @@ -# 5.0.0 (unreleased) +# 5.0.0 (unreleased / master branch) -### WIP (in develop branch currently as its unstable or unfinished) -- Dashboard folders -- User groups -- Dashboard permissions (on folder & dashboard level), permissions can be assigned to groups or individual users -- UX changes to nav & side menu -- New dashboard grid layout system +### New Features +- **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) +- **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. +- **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). +- **Templating**: Vertical repeat direction for panel repeats. +- **UX**: Major update to page header and navigation +- **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) -# 4.7.0 (unreleased) +## New Dashboard Grid + +The new grid engine is major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backwards compatible. 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. + +Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. + +# 4.7.0 (unreleased / v4.7.x branch) ## Breaking changes -`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). +`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). -Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. -From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. +Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. +From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. ## New Features * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) @@ -25,7 +32,8 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when * **Graphite**: Query editor updated to support new query by tag features [#9230](https://github.com/grafana/grafana/issues/9230) * **Dashboard history**: New config file option versions_to_keep sets how many versions per dashboard to store, [#9671](https://github.com/grafana/grafana/issues/9671) * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) - +* **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) +* **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) ## Minor * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend) * **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell) @@ -33,7 +41,7 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when * **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871) * **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798) * **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler) -* **Github**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx +* **Github**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx [@adiletmaratov](https://github.com/adiletmaratov) * **Backend**: Fixed bug where Grafana exited before all sub routines where finished [#10131](https://github.com/grafana/grafana/issues/10131) @@ -46,11 +54,13 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) -# 4.6.3 (unreleased) +# 4.6.3 (2017-12-14) ## Fixes * **Gzip**: Fixes bug gravatar images when gzip was enabled [#5952](https://github.com/grafana/grafana/issues/5952) * **Alert list**: Now shows alert state changes even after adding manual annotations on dashboard [#9951](https://github.com/grafana/grafana/issues/9951) +* **Alerting**: Fixes bug where rules evaluated as firing when all conditions was false and using OR operator. [#9318](https://github.com/grafana/grafana/issues/9318) +* **Cloudwatch**: CloudWatch no longer display metrics' default alias [#10151](https://github.com/grafana/grafana/issues/10151), thx [@mtanda](https://github.com/mtanda) # 4.6.2 (2017-11-16) diff --git a/README.md b/README.md index aefc0c0802b..5358cd3f3d5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides. ## Run from master -If you want to build a package yourself, or contribute. Here is a guide for how to do that. You can always find +If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies @@ -97,7 +97,7 @@ Writing & watching frontend tests (we have two test runners) ## Contribute -If you have any idea for an improvement or found a bug do not hesitate to open an issue. +If you have any idea for an improvement or found a bug, do not hesitate to open an issue. And if you have time clone this repo and submit a pull request and help me make Grafana the kickass metrics & devops dashboard we all dream about! diff --git a/ROADMAP.md b/ROADMAP.md index 4273d8df6a9..479c1933bc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ But it will give you an idea of our current vision and plan. ### Short term (1-4 months) - Release Grafana v5 - - User groups + - Teams - Dashboard folders - Dashboard & folder permissions (assigned to users or groups) - New Dashboard layout engine diff --git a/conf/defaults.ini b/conf/defaults.ini index 218c91608cc..e16ef95178a 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -221,6 +221,9 @@ external_manage_link_url = external_manage_link_name = external_manage_info = +# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. +viewers_can_edit = false + [auth] # Set to true to disable (hide) the login form, useful if you use OAuth disable_login_form = false diff --git a/conf/sample.ini b/conf/sample.ini index 7107f8354d6..544fa7e0c7d 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -205,6 +205,9 @@ log_queries = ;external_manage_link_name = ;external_manage_info = +# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. +;viewers_can_edit = false + [auth] # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false ;disable_login_form = false diff --git a/docker/blocks/graphite/docker-compose.yaml b/docker/blocks/graphite/docker-compose.yaml index 2bd0dc322cc..606e28638f7 100644 --- a/docker/blocks/graphite/docker-compose.yaml +++ b/docker/blocks/graphite/docker-compose.yaml @@ -1,4 +1,4 @@ - graphite: + graphite09: build: blocks/graphite ports: - "8080:80" diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 102134f34dd..0a233ac3577 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -126,25 +126,26 @@ There are couple of configurations options which need to be set in Grafana UI un Once these two properties are set, you can send the alerts to Kafka for further processing or throttling them. -### Other Supported Notification Channels +### All supported notifier -Grafana also supports the following Notification Channels: +Name | Type |Support images +-----|------------ | ------ +Slack | `slack` | yes +Pagerduty | `pagerduty` | yes +Email | `email` | yes +Webhook | `webhook` | link +Kafka | `kafka` | no +Hipchat | `hipchat` | yes +VictorOps | `victorops` | yes +Sensu | `sensu` | yes +OpsGenie | `opsgenie` | yes +Threema | `threema` | yes +Pushover | `pushover` | no +Telegram | `telegram` | no +Line | `line` | no +Prometheus Alertmanager | `prometheus-alertmanager` | no -- HipChat -- VictorOps - -- Sensu - -- OpsGenie - -- Threema - -- Pushover - -- Telegram - -- LINE # Enable images in notifications {#external-image-store} diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index e66218bb066..221552414e9 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -196,6 +196,8 @@ 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. + `POST /api/alert-notifications` **Example Request**: diff --git a/docs/sources/http_api/auth.md b/docs/sources/http_api/auth.md index b526031fdeb..166a5a4fdb9 100644 --- a/docs/sources/http_api/auth.md +++ b/docs/sources/http_api/auth.md @@ -100,7 +100,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk JSON Body schema: - **name** – The key name -- **role** – Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor`, `Read Only Editor` or `Admin`. +- **role** – Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor` or `Admin`. **Example Response**: diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 1333c8c191d..91094b3157d 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -205,7 +205,7 @@ The database user (not applicable for `sqlite3`). ### password -The database user's password (not applicable for `sqlite3`). If the password contains `#` or `;` you have to wrap it with trippel quotes. Ex `"""#password;"""` +The database user's password (not applicable for `sqlite3`). If the password contains `#` or `;` you have to wrap it with triple quotes. Ex `"""#password;"""` ### ssl_mode @@ -214,19 +214,19 @@ For MySQL, use either `true`, `false`, or `skip-verify`. ### ca_cert_path -(MySQL only) The path to the CA certificate to use. On many linux systems, certs can be found in `/etc/ssl/certs`. +The path to the CA certificate to use. On many linux systems, certs can be found in `/etc/ssl/certs`. ### client_key_path -(MySQL only) The path to the client key. Only if server requires client authentication. +The path to the client key. Only if server requires client authentication. ### client_cert_path -(MySQL only) The path to the client cert. Only if server requires client authentication. +The path to the client cert. Only if server requires client authentication. ### server_cert_name -(MySQL only) The common name field of the certificate used by the `mysql` server. Not necessary if `ssl_mode` is set to `skip-verify`. +The common name field of the certificate used by the `mysql` or `postgres` server. Not necessary if `ssl_mode` is set to `skip-verify`. ### max_idle_conn The maximum number of connections in the idle connection pool. @@ -292,10 +292,14 @@ organization to be created for that new user. The role new users will be assigned for the main organization (if the above setting is set to true). Defaults to `Viewer`, other valid -options are `Admin` and `Editor` and `Read Only Editor`. e.g. : +options are `Admin` and `Editor`. e.g. : -`auto_assign_org_role = Read Only Editor` +`auto_assign_org_role = Viewer` +### viewers can edit + +Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. +Defaults to `false`.
diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index d832ea7a8ed..b742e96c869 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -15,9 +15,7 @@ weight = 1 Description | Download ------------ | ------------- -Stable for Debian-based Linux | [grafana_4.6.2_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.2_amd64.deb) - - +Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -26,21 +24,10 @@ installation. ```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.2_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_4.6.2_amd64.deb +sudo dpkg -i grafana_4.6.3_amd64.deb ``` - - - ## APT Repository Add the following line to your `/etc/apt/sources.list` file. diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index fa2bd71ed07..d3e796a78c8 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -15,9 +15,7 @@ weight = 2 Description | Download ------------ | ------------- -Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.2 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2-1.x86_64.rpm) - - +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -27,7 +25,7 @@ installation. You can install Grafana using Yum directly. ```bash -$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2-1.x86_64.rpm +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm ``` Or install manually using `rpm`. @@ -35,15 +33,15 @@ Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: ```bash -$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2-1.x86_64.rpm +$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm $ sudo yum install initscripts fontconfig -$ sudo rpm -Uvh grafana-4.6.2-1.x86_64.rpm +$ sudo rpm -Uvh grafana-4.6.3-1.x86_64.rpm ``` #### On OpenSuse: ```bash -$ sudo rpm -i --nodeps grafana-4.6.2-1.x86_64.rpm +$ sudo rpm -i --nodeps grafana-4.6.3-1.x86_64.rpm ``` ## Install via YUM Repository diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 9cfd689fb43..7c6a97085df 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -13,7 +13,7 @@ weight = 3 Description | Download ------------ | ------------- -Latest stable package for Windows | [grafana.4.6.2.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2.windows-x64.zip) +Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/jest.config.js b/jest.config.js index cbe77c4e30f..ead97e39dad 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ module.exports = { - verbose: true, + verbose: false, "globals": { "ts-jest": { "tsConfigFile": "tsconfig.json" diff --git a/package.json b/package.json index 9a6edfb8be9..6bcd140cc1b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "4.7.0-pre1", + "version": "5.0.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -14,8 +14,8 @@ "@types/enzyme": "^2.8.9", "@types/jest": "^21.1.4", "@types/node": "^8.0.31", - "@types/react": "^16.0.5", - "@types/react-dom": "^15.5.4", + "@types/react": "^16.0.25", + "@types/react-dom": "^16.0.3", "angular-mocks": "^1.6.6", "autoprefixer": "^6.4.0", "awesome-typescript-loader": "^3.2.3", @@ -115,22 +115,26 @@ "angular-sanitize": "^1.6.6", "babel-polyfill": "^6.26.0", "brace": "^0.10.0", + "classnames": "^2.2.5", "clipboard": "^1.7.1", - "eventemitter3": "^2.0.3", + "d3": "^4.11.0", + "d3-scale-chromatic": "^1.1.1", + "eventemitter3": "^2.0.2", "file-saver": "^1.3.3", "jquery": "^3.2.1", "lodash": "^4.17.4", "moment": "^2.18.1", "mousetrap": "^1.6.0", - "ngreact": "^0.4.1", - "react": "^16.0.0", - "react-dom": "^16.0.0", + "perfect-scrollbar": "^1.2.0", + "prop-types": "^15.6.0", + "react": "^16.1.1", + "react-dom": "^16.1.1", + "react-grid-layout": "^0.16.1", + "react-sizeme": "^2.3.6", "remarkable": "^1.7.1", "rxjs": "^5.4.3", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop", - "tinycolor2": "^1.4.1", - "d3": "^4.11.0", - "d3-scale-chromatic": "^1.1.1" + "tinycolor2": "^1.4.1" } } diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index 6c4f5a5c29a..9736cbddd6c 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,5 +1,5 @@ #! /usr/bin/env bash -version=4.6.2 +version=4.6.3 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb diff --git a/pkg/api/api.go b/pkg/api/api.go index 3810f87dbb8..3f6d8d4d954 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -40,9 +40,11 @@ func (hs *HttpServer) registerRoutes() { r.Get("/datasources/", reqSignedIn, Index) r.Get("/datasources/new", reqSignedIn, Index) r.Get("/datasources/edit/*", reqSignedIn, Index) - r.Get("/org/users/", reqSignedIn, Index) + r.Get("/org/users/new", reqSignedIn, Index) + r.Get("/org/users/invite", reqSignedIn, Index) r.Get("/org/apikeys/", reqSignedIn, Index) r.Get("/dashboard/import/", reqSignedIn, Index) + r.Get("/configuration", reqGrafanaAdmin, Index) r.Get("/admin", reqGrafanaAdmin, Index) r.Get("/admin/settings", reqGrafanaAdmin, Index) r.Get("/admin/users", reqGrafanaAdmin, Index) @@ -62,6 +64,7 @@ func (hs *HttpServer) registerRoutes() { r.Get("/dashboard-solo/snapshot/*", Index) r.Get("/dashboard-solo/*", reqSignedIn, Index) r.Get("/import/dashboard", reqSignedIn, Index) + r.Get("/dashboards/", reqSignedIn, Index) r.Get("/dashboards/*", reqSignedIn, Index) r.Get("/playlists/", reqSignedIn, Index) @@ -134,6 +137,18 @@ func (hs *HttpServer) registerRoutes() { usersRoute.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg)) }, reqGrafanaAdmin) + // team (admin permission required) + apiRoute.Group("/teams", func(teamsRoute RouteRegister) { + teamsRoute.Get("/:teamId", wrap(GetTeamById)) + teamsRoute.Get("/search", wrap(SearchTeams)) + teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) + teamsRoute.Delete("/:teamId", wrap(DeleteTeamById)) + teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers)) + teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) + }, reqOrgAdmin) + // org information available to all users. apiRoute.Group("/org", func(orgRoute RouteRegister) { orgRoute.Get("/", wrap(GetOrgCurrent)) @@ -224,12 +239,8 @@ func (hs *HttpServer) registerRoutes() { // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { - dashboardRoute.Get("/db/:slug", GetDashboard) - dashboardRoute.Delete("/db/:slug", reqEditorRole, DeleteDashboard) - - dashboardRoute.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions)) - dashboardRoute.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion)) - dashboardRoute.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) + dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard)) dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff)) @@ -237,6 +248,18 @@ func (hs *HttpServer) registerRoutes() { dashboardRoute.Get("/home", wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) + + dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { + dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) + dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) + dashIdRoute.Post("/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + + dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { + aclRoute.Get("/", wrap(GetDashboardAclList)) + aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) + aclRoute.Delete("/:aclId", wrap(DeleteDashboardAcl)) + }) + }) }) // Dashboard snapshots diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index fdf93d06b5d..6824e330f00 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -25,6 +25,8 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" + + gocache "github.com/patrickmn/go-cache" ) var gravatarSource string @@ -92,7 +94,7 @@ func (this *Avatar) Update() (err error) { type CacheServer struct { notFound *Avatar - cache map[string]*Avatar + cache *gocache.Cache } func (this *CacheServer) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) { @@ -110,7 +112,9 @@ func (this *CacheServer) Handler(ctx *macaron.Context) { var avatar *Avatar - if avatar, _ = this.cache[hash]; avatar == nil { + if obj, exist := this.cache.Get(hash); exist { + avatar = obj.(*Avatar) + } else { avatar = New(hash) } @@ -124,7 +128,7 @@ func (this *CacheServer) Handler(ctx *macaron.Context) { if avatar.notFound { avatar = this.notFound } else { - this.cache[hash] = avatar + this.cache.Add(hash, avatar, gocache.DefaultExpiration) } ctx.Resp.Header().Add("Content-Type", "image/jpeg") @@ -146,7 +150,7 @@ func NewCacheServer() *CacheServer { return &CacheServer{ notFound: newNotFound(), - cache: make(map[string]*Avatar), + cache: gocache.New(time.Hour, time.Hour*2), } } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 92d4a13d39e..87c42884e31 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path" - "strings" "github.com/grafana/grafana/pkg/services/dashboards" @@ -18,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -35,23 +35,34 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error) return query.Result, nil } -func GetDashboard(c *middleware.Context) { - slug := strings.ToLower(c.Params(":slug")) - - query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId} - err := bus.Dispatch(&query) +func dashboardGuardianResponse(err error) Response { if err != nil { - c.JsonApiErr(404, "Dashboard not found", nil) - return + return ApiError(500, "Error while checking dashboard permissions", err) + } else { + return ApiError(403, "Access denied to this dashboard", nil) + } +} + +func GetDashboard(c *middleware.Context) Response { + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0) + if rsp != nil { + return rsp } - isStarred, err := isDashboardStarredByUser(c, query.Result.Id) - if err != nil { - c.JsonApiErr(500, "Error while checking if dashboard was starred by user", err) - return + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canView, err := guardian.CanView(); err != nil || !canView { + fmt.Printf("%v", err) + return dashboardGuardianResponse(err) } - dash := query.Result + canEdit, _ := guardian.CanEdit() + canSave, _ := guardian.CanSave() + canAdmin, _ := guardian.CanAdmin() + + isStarred, err := isDashboardStarredByUser(c, dash.Id) + if err != nil { + return ApiError(500, "Error while checking if dashboard was starred by user", err) + } // Finding creator and last updater of the dashboard updater, creator := "Anonymous", "Anonymous" @@ -62,29 +73,44 @@ func GetDashboard(c *middleware.Context) { creator = getUserLogin(dash.CreatedBy) } + meta := dtos.DashboardMeta{ + IsStarred: isStarred, + Slug: dash.Slug, + Type: m.DashTypeDB, + CanStar: c.IsSignedIn, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + Created: dash.Created, + Updated: dash.Updated, + UpdatedBy: updater, + CreatedBy: creator, + Version: dash.Version, + HasAcl: dash.HasAcl, + IsFolder: dash.IsFolder, + FolderId: dash.FolderId, + FolderTitle: "Root", + } + + // lookup folder title + if dash.FolderId > 0 { + query := m.GetDashboardQuery{Id: dash.FolderId, OrgId: c.OrgId} + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Dashboard folder could not be read", err) + } + meta.FolderTitle = query.Result.Title + } + // make sure db version is in sync with json model version dash.Data.Set("version", dash.Version) dto := dtos.DashboardFullWithMeta{ Dashboard: dash.Data, - Meta: dtos.DashboardMeta{ - IsStarred: isStarred, - Slug: slug, - Type: m.DashTypeDB, - CanStar: c.IsSignedIn, - CanSave: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR, - CanEdit: canEditDashboard(c.OrgRole), - Created: dash.Created, - Updated: dash.Updated, - UpdatedBy: updater, - CreatedBy: creator, - Version: dash.Version, - }, + Meta: meta, } - // TODO(ben): copy this performance metrics logic for the new API endpoints added c.TimeRequest(metrics.M_Api_Dashboard_Get) - c.JSON(200, dto) + return Json(200, dto) } func getUserLogin(userId int64) string { @@ -98,24 +124,32 @@ func getUserLogin(userId int64) string { } } -func DeleteDashboard(c *middleware.Context) { - slug := c.Params(":slug") - - query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId} +func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) { + query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(404, "Dashboard not found", nil) - return + return nil, ApiError(404, "Dashboard not found", err) + } + return query.Result, nil +} + +func DeleteDashboard(c *middleware.Context) Response { + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0) + if rsp != nil { + return rsp } - cmd := m.DeleteDashboardCommand{Slug: slug, OrgId: c.OrgId} + 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 { - c.JsonApiErr(500, "Failed to delete dashboard", err) - return + return ApiError(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": query.Result.Title} - - c.JSON(200, resp) + var resp = map[string]interface{}{"title": dash.Title} + return Json(200, resp) } func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { @@ -124,6 +158,20 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { dash := cmd.GetDashboardModel() + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + if dash.IsFolder && dash.FolderId > 0 { + return ApiError(400, m.ErrDashboardFolderCannotHaveParent.Error(), nil) + } + + // Check if Title is empty + if dash.Title == "" { + return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil) + } + if dash.Id == 0 { limitReached, err := middleware.QuotaReached(c, "dashboard") if err != nil { @@ -139,6 +187,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { Message: cmd.Message, OrgId: c.OrgId, UserId: c.UserId, + Overwrite: cmd.Overwrite, } dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem) @@ -177,11 +226,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { } c.TimeRequest(metrics.M_Api_Dashboard_Save) - return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version}) -} - -func canEditDashboard(role m.RoleType) bool { - return role == m.ROLE_ADMIN || role == m.ROLE_EDITOR || role == m.ROLE_READ_ONLY_EDITOR + return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id}) } func GetHomeDashboard(c *middleware.Context) Response { @@ -209,7 +254,9 @@ func GetHomeDashboard(c *middleware.Context) Response { dash := dtos.DashboardFullWithMeta{} dash.Meta.IsHome = true - dash.Meta.CanEdit = canEditDashboard(c.OrgRole) + dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR) + dash.Meta.FolderTitle = "Root" + jsonParser := json.NewDecoder(file) if err := jsonParser.Decode(&dash.Dashboard); err != nil { return ApiError(500, "Failed to load home dashboard", err) @@ -223,39 +270,41 @@ func GetHomeDashboard(c *middleware.Context) Response { } func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) { - rows := dash.Get("rows").MustArray() - row := simplejson.NewFromAny(rows[0]) + panels := dash.Get("panels").MustArray() newpanel := simplejson.NewFromAny(map[string]interface{}{ "type": "gettingstarted", "id": 123123, - "span": 12, + "gridPos": map[string]interface{}{ + "x": 0, + "y": 3, + "w": 24, + "h": 4, + }, }) - panels := row.Get("panels").MustArray() panels = append(panels, newpanel) - row.Set("panels", panels) + dash.Set("panels", panels) } // GetDashboardVersions returns all dashboard versions as JSON func GetDashboardVersions(c *middleware.Context) Response { - dashboardId := c.ParamsInt64(":dashboardId") - limit := c.QueryInt("limit") - start := c.QueryInt("start") + dashId := c.ParamsInt64(":dashboardId") - if limit == 0 { - limit = 1000 + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) } query := m.GetDashboardVersionsQuery{ OrgId: c.OrgId, - DashboardId: dashboardId, - Limit: limit, - Start: start, + DashboardId: dashId, + Limit: c.QueryInt("limit"), + Start: c.QueryInt("start"), } if err := bus.Dispatch(&query); err != nil { - return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashboardId), err) + return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashId), err) } for _, version := range query.Result { @@ -279,17 +328,21 @@ func GetDashboardVersions(c *middleware.Context) Response { // GetDashboardVersion returns the dashboard version with the given ID. func GetDashboardVersion(c *middleware.Context) Response { - dashboardId := c.ParamsInt64(":dashboardId") - version := c.ParamsInt(":id") + dashId := c.ParamsInt64(":dashboardId") + + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } query := m.GetDashboardVersionQuery{ OrgId: c.OrgId, - DashboardId: dashboardId, - Version: version, + DashboardId: dashId, + Version: c.ParamsInt(":id"), } if err := bus.Dispatch(&query); err != nil { - return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", version, dashboardId), err) + return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", query.Version, dashId), err) } creator := "Anonymous" @@ -340,19 +393,21 @@ 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 { - dashboardId := c.ParamsInt64(":dashboardId") - - dashQuery := m.GetDashboardQuery{Id: dashboardId, OrgId: c.OrgId} - if err := bus.Dispatch(&dashQuery); err != nil { - return ApiError(404, "Dashboard not found", nil) + dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId")) + if rsp != nil { + return rsp } - versionQuery := m.GetDashboardVersionQuery{DashboardId: dashboardId, Version: apiCmd.Version, OrgId: c.OrgId} + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + versionQuery := m.GetDashboardVersionQuery{DashboardId: dash.Id, Version: apiCmd.Version, OrgId: c.OrgId} if err := bus.Dispatch(&versionQuery); err != nil { return ApiError(404, "Dashboard version not found", nil) } - dashboard := dashQuery.Result version := versionQuery.Result saveCmd := m.SaveDashboardCommand{} @@ -360,7 +415,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard saveCmd.OrgId = c.OrgId saveCmd.UserId = c.UserId saveCmd.Dashboard = version.Data - saveCmd.Dashboard.Set("version", dashboard.Version) + saveCmd.Dashboard.Set("version", dash.Version) 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 new file mode 100644 index 00000000000..88cc74b9d1c --- /dev/null +++ b/pkg/api/dashboard_acl.go @@ -0,0 +1,79 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" +) + +func GetDashboardAclList(c *middleware.Context) Response { + dashId := c.ParamsInt64(":dashboardId") + + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + + if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { + return dashboardGuardianResponse(err) + } + + acl, err := guardian.GetAcl() + if err != nil { + return ApiError(500, "Failed to get dashboard acl", err) + } + + return Json(200, acl) +} + +func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { + dashId := c.ParamsInt64(":dashboardId") + + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { + return dashboardGuardianResponse(err) + } + + cmd := m.UpdateDashboardAclCommand{} + cmd.DashboardId = dashId + + for _, item := range apiCmd.Items { + cmd.Items = append(cmd.Items, &m.DashboardAcl{ + OrgId: c.OrgId, + DashboardId: dashId, + UserId: item.UserId, + TeamId: item.TeamId, + Role: item.Role, + Permission: item.Permission, + Created: time.Now(), + Updated: time.Now(), + }) + } + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty { + return ApiError(409, err.Error(), err) + } + return ApiError(500, "Failed to create permission", err) + } + + return ApiSuccess("Dashboard acl updated") +} + +func DeleteDashboardAcl(c *middleware.Context) Response { + dashId := c.ParamsInt64(":dashboardId") + aclId := c.ParamsInt64(":aclId") + + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { + return dashboardGuardianResponse(err) + } + + cmd := m.RemoveDashboardAclCommand{OrgId: c.OrgId, AclId: aclId} + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "Failed to delete permission for user", err) + } + + return Json(200, "") +} diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go new file mode 100644 index 00000000000..e22e625dcf9 --- /dev/null +++ b/pkg/api/dashboard_acl_test.go @@ -0,0 +1,174 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardAclApiEndpoint(t *testing.T) { + Convey("Given a dashboard acl", t, func() { + mockResult := []*m.DashboardAclInfoDTO{ + {Id: 1, OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, + {Id: 2, OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, + {Id: 3, OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, + {Id: 4, OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {Id: 5, OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, + } + dtoRes := transformDashboardAclsToDTOs(mockResult) + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = dtoRes + return nil + }) + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + teamResp := []*m.Team{} + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = teamResp + return nil + }) + + Convey("When user is org admin", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { + Convey("Should be able to access ACL", func() { + sc.handlerFunc = GetDashboardAclList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(len(respJSON.MustArray()), ShouldEqual, 5) + So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) + So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) + }) + }) + }) + + Convey("When user is editor and has admin permission in the ACL", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + Convey("Should be able to access ACL", func() { + sc.handlerFunc = GetDashboardAclList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { + return nil + }) + + Convey("Should be able to delete permission", func() { + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When user is a member of a team in the ACL with admin permission", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"}) + + bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { + return nil + }) + + Convey("Should be able to delete permission", func() { + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) + + Convey("When user is editor and has edit permission in the ACL", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) + + Convey("Should not be able to access ACL", func() { + sc.handlerFunc = GetDashboardAclList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) + + bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { + return nil + }) + + Convey("Should be not be able to delete permission", func() { + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is editor and not in the ACL", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + + Convey("Should not be able to access ACL", func() { + sc.handlerFunc = GetDashboardAclList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/user/1", "/api/dashboards/id/:dashboardsId/acl/user/:userId", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}) + bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { + return nil + }) + + Convey("Should be not be able to delete permission", func() { + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + }) +} + +func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardAclInfoDTO { + dtos := make([]*m.DashboardAclInfoDTO, 0) + + for _, acl := range acls { + dto := &m.DashboardAclInfoDTO{ + Id: acl.Id, + OrgId: acl.OrgId, + DashboardId: acl.DashboardId, + Permission: acl.Permission, + UserId: acl.UserId, + TeamId: acl.TeamId, + } + dtos = append(dtos, dto) + } + + return dtos +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go new file mode 100644 index 00000000000..e6228878625 --- /dev/null +++ b/pkg/api/dashboard_test.go @@ -0,0 +1,521 @@ +package api + +import ( + "encoding/json" + "path/filepath" + "testing" + + macaron "gopkg.in/macaron.v1" + + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/setting" + + . "github.com/smartystreets/goconvey/convey" +) + +type fakeDashboardRepo struct { + inserted []*dashboards.SaveDashboardItem + getDashboard []*m.Dashboard +} + +func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*m.Dashboard, error) { + repo.inserted = append(repo.inserted, json) + return json.Dashboard, nil +} + +var fakeRepo *fakeDashboardRepo + +func TestDashboardApiEndpoint(t *testing.T) { + Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { + fakeDash := m.NewDashboard("Child dash") + fakeDash.Id = 1 + fakeDash.FolderId = 1 + fakeDash.HasAcl = false + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeDash + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + cmd := m.SaveDashboardCommand{ + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "folderId": fakeDash.FolderId, + "title": fakeDash.Title, + "id": fakeDash.Id, + }), + } + + 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) { + dash := GetDashboardShouldReturn200(sc) + + 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/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + 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) { + dash := GetDashboardShouldReturn200(sc) + + 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/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + Convey("When saving a dashboard folder in another folder", func() { + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeDash + query.Result.IsFolder = true + return nil + }) + invalidCmd := m.SaveDashboardCommand{ + FolderId: fakeDash.FolderId, + IsFolder: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "folderId": fakeDash.FolderId, + "title": fakeDash.Title, + }), + } + Convey("Should return an error", func() { + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, invalidCmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + }) + }) + }) + + Convey("Given a dashboard with a parent folder which has an acl", t, func() { + fakeDash := m.NewDashboard("Child dash") + fakeDash.Id = 1 + fakeDash.FolderId = 1 + fakeDash.HasAcl = true + setting.ViewersCanEdit = false + + aclMockResp := []*m.DashboardAclInfoDTO{ + { + DashboardId: 1, + Permission: m.PERMISSION_EDIT, + UserId: 200, + }, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeDash + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + cmd := m.SaveDashboardCommand{ + FolderId: fakeDash.FolderId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": fakeDash.Id, + "folderId": fakeDash.FolderId, + "title": fakeDash.Title, + }), + } + + 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) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + 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) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + 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) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + 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) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("When user is an Org Viewer but has an edit permission", func() { + role := m.ROLE_VIEWER + + mockResult := []*m.DashboardAclInfoDTO{ + {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + 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/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When user is an Org Viewer and viewers can edit", func() { + role := m.ROLE_VIEWER + setting.ViewersCanEdit = true + + mockResult := []*m.DashboardAclInfoDTO{ + {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + 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/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("When user is an Org Viewer but has an admin permission", func() { + role := m.ROLE_VIEWER + + mockResult := []*m.DashboardAclInfoDTO{ + {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + 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/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When user is an Org Editor but has a view permission", func() { + role := m.ROLE_EDITOR + + mockResult := []*m.DashboardAclInfoDTO{ + {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + 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) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { + CallGetDashboardVersion(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { + CallGetDashboardVersions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) +} + +func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + + dash := dtos.DashboardFullWithMeta{} + err := json.NewDecoder(sc.resp.Body).Decode(&dash) + So(err, ShouldBeNil) + + return dash +} + +func CallGetDashboardVersion(sc *scenarioContext) { + bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { + query.Result = &m.DashboardVersion{} + return nil + }) + + sc.handlerFunc = GetDashboardVersion + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func CallGetDashboardVersions(sc *scenarioContext) { + bus.AddHandler("test", func(query *m.GetDashboardVersionsQuery) error { + query.Result = []*m.DashboardVersionDTO{} + return nil + }) + + sc.handlerFunc = GetDashboardVersions + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func CallDeleteDashboard(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error { + return nil + }) + + sc.handlerFunc = DeleteDashboard + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() +} + +func CallPostDashboard(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { + cmd.Result = &m.Dashboard{Id: 2, Slug: "Dash", Version: 2} + return nil + }) + + bus.AddHandler("test", func(cmd *alerting.UpdateDashboardAlertsCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PostDashboard(c, cmd) + }) + + fakeRepo = &fakeDashboardRepo{} + dashboards.SetRepository(fakeRepo) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 5ae752bea91..72336693363 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -56,6 +56,10 @@ func TestDataSourcesProxy(t *testing.T) { } func loggedInUserScenario(desc string, url string, fn scenarioFunc) { + loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) +} + +func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() @@ -77,7 +81,7 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID - sc.context.OrgRole = models.ROLE_EDITOR + sc.context.OrgRole = role if sc.handlerFunc != nil { return sc.handlerFunc(sc.context) } @@ -85,7 +89,12 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) { return nil }) - sc.m.Get(url, sc.defaultHandler) + switch method { + case "GET": + sc.m.Get(routePattern, sc.defaultHandler) + case "DELETE": + sc.m.Delete(routePattern, sc.defaultHandler) + } fn(sc) }) diff --git a/pkg/api/dtos/acl.go b/pkg/api/dtos/acl.go new file mode 100644 index 00000000000..6c74e68ce0d --- /dev/null +++ b/pkg/api/dtos/acl.go @@ -0,0 +1,16 @@ +package dtos + +import ( + m "github.com/grafana/grafana/pkg/models" +) + +type UpdateDashboardAclCommand struct { + Items []DashboardAclUpdateItem `json:"items"` +} + +type DashboardAclUpdateItem struct { + UserId int64 `json:"userId"` + TeamId int64 `json:"teamId"` + Role *m.RoleType `json:"role,omitempty"` + Permission m.PermissionType `json:"permission"` +} diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 9ef9a96edc4..0be0537527b 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -7,20 +7,25 @@ import ( ) type DashboardMeta struct { - IsStarred bool `json:"isStarred,omitempty"` - IsHome bool `json:"isHome,omitempty"` - IsSnapshot bool `json:"isSnapshot,omitempty"` - Type string `json:"type,omitempty"` - CanSave bool `json:"canSave"` - CanEdit bool `json:"canEdit"` - CanStar bool `json:"canStar"` - Slug string `json:"slug"` - Expires time.Time `json:"expires"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - UpdatedBy string `json:"updatedBy"` - CreatedBy string `json:"createdBy"` - Version int `json:"version"` + IsStarred bool `json:"isStarred,omitempty"` + IsHome bool `json:"isHome,omitempty"` + IsSnapshot bool `json:"isSnapshot,omitempty"` + Type string `json:"type,omitempty"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanStar bool `json:"canStar"` + Slug string `json:"slug"` + Expires time.Time `json:"expires"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + UpdatedBy string `json:"updatedBy"` + CreatedBy string `json:"createdBy"` + Version int `json:"version"` + HasAcl bool `json:"hasAcl"` + IsFolder bool `json:"isFolder"` + FolderId int64 `json:"folderId"` + FolderTitle string `json:"folderTitle"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index b813c78f2bb..8c7f505277d 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -7,9 +7,10 @@ type IndexViewData struct { AppSubUrl string GoogleAnalyticsId string GoogleTagManagerId string - MainNavLinks []*NavLink + NavTree []*NavLink BuildVersion string BuildCommit string + Theme string NewGrafanaVersionExists bool NewGrafanaVersion string } @@ -20,10 +21,16 @@ type PluginCss struct { } type NavLink struct { - Text string `json:"text,omitempty"` - Icon string `json:"icon,omitempty"` - Img string `json:"img,omitempty"` - Url string `json:"url,omitempty"` - Divider bool `json:"divider,omitempty"` - Children []*NavLink `json:"children,omitempty"` + Id string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Description string `json:"description,omitempty"` + SubTitle string `json:"subTitle,omitempty"` + Icon string `json:"icon,omitempty"` + Img string `json:"img,omitempty"` + Url string `json:"url,omitempty"` + Target string `json:"target,omitempty"` + Divider bool `json:"divider,omitempty"` + HideFromMenu bool `json:"hideFromMenu,omitempty"` + HideFromTabs bool `json:"hideFromTabs,omitempty"` + Children []*NavLink `json:"children,omitempty"` } diff --git a/pkg/api/dtos/invite.go b/pkg/api/dtos/invite.go index 3f002a8b157..09d53f576b4 100644 --- a/pkg/api/dtos/invite.go +++ b/pkg/api/dtos/invite.go @@ -6,7 +6,7 @@ type AddInviteForm struct { LoginOrEmail string `json:"loginOrEmail" binding:"Required"` Name string `json:"name"` Role m.RoleType `json:"role" binding:"Required"` - SkipEmails bool `json:"skipEmails"` + SendEmail bool `json:"sendEmail"` } type InviteInfo struct { diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index e2aa12249aa..2e9aa78d7d5 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -27,6 +27,7 @@ type CurrentUser struct { Email string `json:"email"` Name string `json:"name"` LightTheme bool `json:"lightTheme"` + OrgCount int `json:"orgCount"` OrgId int64 `json:"orgId"` OrgName string `json:"orgName"` OrgRole m.RoleType `json:"orgRole"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 563f940904e..591dcc62344 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -143,7 +143,6 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "alertingEnabled": setting.AlertingEnabled, "googleAnalyticsId": setting.GoogleAnalyticsId, "disableLoginForm": setting.DisableLoginForm, - "disableSignoutMenu": setting.DisableSignoutMenu, "externalUserMngInfo": setting.ExternalUserMngInfo, "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkName": setting.ExternalUserMngLinkName, diff --git a/pkg/api/index.go b/pkg/api/index.go index bf7a9fc1759..6629c2f5ca5 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -50,6 +50,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { Login: c.Login, Email: c.Email, Name: c.Name, + OrgCount: c.OrgCount, OrgId: c.OrgId, OrgName: c.OrgName, OrgRole: c.OrgRole, @@ -61,6 +62,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { HelpFlags1: c.HelpFlags1, }, Settings: settings, + Theme: prefs.Theme, AppUrl: appUrl, AppSubUrl: appSubUrl, GoogleAnalyticsId: setting.GoogleAnalyticsId, @@ -82,52 +84,77 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { themeUrlParam := c.Query("theme") if themeUrlParam == "light" { data.User.LightTheme = true - } - - dashboardChildNavs := []*dtos.NavLink{ - {Text: "Home", Url: setting.AppSubUrl + "/"}, - {Text: "Playlists", Url: setting.AppSubUrl + "/playlists"}, - {Text: "Snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots"}, + data.Theme = "light" } if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Divider: true}) - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "New", Icon: "fa fa-plus", Url: setting.AppSubUrl + "/dashboard/new"}) - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "Import", Icon: "fa fa-download", Url: setting.AppSubUrl + "/dashboard/new/?editview=import"}) + data.NavTree = append(data.NavTree, &dtos.NavLink{ + Text: "Create", + Id: "create", + Icon: "fa fa-fw fa-plus", + Url: setting.AppSubUrl + "dashboard/new", + Children: []*dtos.NavLink{ + {Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"}, + {Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"}, + {Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"}, + }, + }) } - data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ + dashboardChildNavs := []*dtos.NavLink{ + {Text: "Home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true}, + {Divider: true, HideFromTabs: true}, + {Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "gicon gicon-manage"}, + {Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "gicon gicon-playlists"}, + {Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "gicon gicon-snapshots"}, + } + + data.NavTree = append(data.NavTree, &dtos.NavLink{ Text: "Dashboards", - Icon: "icon-gf icon-gf-dashboard", + Id: "dashboards", + SubTitle: "Manage dashboards & folders", + Icon: "gicon gicon-dashboard", Url: setting.AppSubUrl + "/", Children: dashboardChildNavs, }) - if setting.AlertingEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) { - alertChildNavs := []*dtos.NavLink{ - {Text: "Alert List", Url: setting.AppSubUrl + "/alerting/list"}, - {Text: "Notification channels", Url: setting.AppSubUrl + "/alerting/notifications"}, + if c.IsSignedIn { + profileNode := &dtos.NavLink{ + Text: c.SignedInUser.NameOrFallback(), + SubTitle: c.SignedInUser.Login, + Id: "profile", + Img: data.User.GravatarUrl, + Url: setting.AppSubUrl + "/profile", + HideFromMenu: true, + Children: []*dtos.NavLink{ + {Text: "Preferences", Id: "profile-settings", Url: setting.AppSubUrl + "/profile", Icon: "gicon gicon-preferences"}, + {Text: "Change Password", Id: "change-password", Url: setting.AppSubUrl + "/profile/password", Icon: "fa fa-fw fa-lock", HideFromMenu: true}, + }, } - data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ - Text: "Alerting", - Icon: "icon-gf icon-gf-alert", - Url: setting.AppSubUrl + "/alerting/list", - Children: alertChildNavs, - }) + if !setting.DisableSignoutMenu { + // add sign out first + profileNode.Children = append(profileNode.Children, &dtos.NavLink{ + Text: "Sign out", Id: "sign-out", Url: setting.AppSubUrl + "/logout", Icon: "fa fa-fw fa-sign-out", Target: "_self", + }) + } + + data.NavTree = append(data.NavTree, profileNode) } - if c.OrgRole == m.ROLE_ADMIN { - data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ - Text: "Data Sources", - Icon: "icon-gf icon-gf-datasources", - Url: setting.AppSubUrl + "/datasources", - }) + if setting.AlertingEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) { + alertChildNavs := []*dtos.NavLink{ + {Text: "Alert Rules", Id: "alert-list", Url: setting.AppSubUrl + "/alerting/list", Icon: "gicon gicon-alert-rules"}, + {Text: "Notification channels", Id: "channels", Url: setting.AppSubUrl + "/alerting/notifications", Icon: "gicon gicon-alert-notification-channel"}, + } - data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ - Text: "Plugins", - Icon: "icon-gf icon-gf-apps", - Url: setting.AppSubUrl + "/plugins", + data.NavTree = append(data.NavTree, &dtos.NavLink{ + Text: "Alerting", + SubTitle: "Alert rules & notifications", + Id: "alerting", + Icon: "gicon gicon-alert", + Url: setting.AppSubUrl + "/alerting/list", + Children: alertChildNavs, }) } @@ -140,6 +167,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { if plugin.Pinned { appLink := &dtos.NavLink{ Text: plugin.Name, + Id: "plugin-page-" + plugin.Id, Url: plugin.DefaultNavUrl, Img: plugin.Info.Logos.Small, } @@ -168,29 +196,106 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { if len(appLink.Children) > 0 && c.OrgRole == m.ROLE_ADMIN { appLink.Children = append(appLink.Children, &dtos.NavLink{Divider: true}) - appLink.Children = append(appLink.Children, &dtos.NavLink{Text: "Plugin Config", Icon: "fa fa-cog", Url: setting.AppSubUrl + "/plugins/" + plugin.Id + "/edit"}) + appLink.Children = append(appLink.Children, &dtos.NavLink{Text: "Plugin Config", Icon: "gicon gicon-cog", Url: setting.AppSubUrl + "/plugins/" + plugin.Id + "/edit"}) } if len(appLink.Children) > 0 { - data.MainNavLinks = append(data.MainNavLinks, appLink) + data.NavTree = append(data.NavTree, appLink) } } } - if c.IsGrafanaAdmin { - data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ - Text: "Admin", - Icon: "fa fa-fw fa-cogs", - Url: setting.AppSubUrl + "/admin", + if c.OrgRole == m.ROLE_ADMIN { + cfgNode := &dtos.NavLink{ + Id: "cfg", + Text: "Configuration", + SubTitle: "Organization: " + c.OrgName, + Icon: "gicon gicon-cog", + Url: setting.AppSubUrl + "/datasources", Children: []*dtos.NavLink{ - {Text: "Global Users", Url: setting.AppSubUrl + "/admin/users"}, - {Text: "Global Orgs", Url: setting.AppSubUrl + "/admin/orgs"}, - {Text: "Server Settings", Url: setting.AppSubUrl + "/admin/settings"}, - {Text: "Server Stats", Url: setting.AppSubUrl + "/admin/stats"}, + { + Text: "Data Sources", + Icon: "gicon gicon-datasources", + Description: "Add and configure data sources", + Id: "datasources", + Url: setting.AppSubUrl + "/datasources", + }, + { + Text: "Users", + Id: "users", + Description: "Manage org members", + Icon: "gicon gicon-user", + Url: setting.AppSubUrl + "/org/users", + }, + { + Text: "Teams", + Id: "teams", + Description: "Manage org groups", + Icon: "gicon gicon-team", + Url: setting.AppSubUrl + "/org/teams", + }, + { + Text: "Plugins", + Id: "plugins", + Description: "View and configure plugins", + Icon: "gicon gicon-plugins", + Url: setting.AppSubUrl + "/plugins", + }, + { + Text: "Preferences", + Id: "org-settings", + Description: "Organization preferences", + Icon: "gicon gicon-preferences", + Url: setting.AppSubUrl + "/org", + }, + + { + Text: "API Keys", + Id: "apikeys", + Description: "Create & manage API keys", + Icon: "gicon gicon-apikeys", + Url: setting.AppSubUrl + "/org/apikeys", + }, }, - }) + } + + if c.IsGrafanaAdmin { + cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ + Divider: true, HideFromTabs: true, + }) + cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ + Text: "Server Admin", + HideFromTabs: true, + SubTitle: "Manage all users & orgs", + Id: "admin", + Icon: "gicon gicon-shield", + Url: setting.AppSubUrl + "/admin/users", + Children: []*dtos.NavLink{ + {Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"}, + {Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"}, + {Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"}, + {Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"}, + {Text: "Style Guide", Id: "styleguide", Url: setting.AppSubUrl + "/styleguide", Icon: "fa fa-fw fa-eyedropper"}, + }, + }) + } + + data.NavTree = append(data.NavTree, cfgNode) } + data.NavTree = append(data.NavTree, &dtos.NavLink{ + Text: "Help", + Id: "help", + Url: "#", + Icon: "gicon gicon-question", + HideFromMenu: true, + Children: []*dtos.NavLink{ + {Text: "Keyboard shortcuts", Url: "/shortcuts", Icon: "fa fa-fw fa-keyboard-o", Target: "_self"}, + {Text: "Community site", Url: "http://community.grafana.com", Icon: "fa fa-fw fa-comment", Target: "_blank"}, + {Text: "Documentation", Url: "http://docs.grafana.org", Icon: "fa fa-fw fa-file", Target: "_blank"}, + }, + }) + return &data, nil } diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 864e464133d..57d9913d2eb 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -61,7 +61,7 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response } // send invite email - if !inviteDto.SkipEmails && util.IsEmail(inviteDto.LoginOrEmail) { + if inviteDto.SendEmail && util.IsEmail(inviteDto.LoginOrEmail) { emailCmd := m.SendEmailCommand{ To: []string{inviteDto.LoginOrEmail}, Template: "new_user_invite.html", @@ -99,7 +99,7 @@ func inviteExistingUserToOrg(c *middleware.Context, user *m.User, inviteDto *dto return ApiError(500, "Error while trying to create org user", err) } else { - if !inviteDto.SkipEmails && util.IsEmail(user.Email) { + if inviteDto.SendEmail && util.IsEmail(user.Email) { emailCmd := m.SendEmailCommand{ To: []string{user.Email}, Template: "invited_to_org.html", diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 02c376eed30..57a15bd8db5 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -1,6 +1,7 @@ package api import ( + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -31,10 +32,6 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { userToAdd := userQuery.Result - // if userToAdd.Id == c.UserId { - // return ApiError(400, "Cannot add yourself as user", nil) - // } - cmd.UserId = userToAdd.Id if err := bus.Dispatch(&cmd); err != nil { @@ -64,6 +61,10 @@ func getOrgUsersHelper(orgId int64) Response { return ApiError(500, "Failed to get account user", err) } + for _, user := range query.Result { + user.AvatarUrl = dtos.GetGravatarUrl(user.Email) + } + return Json(200, query.Result) } diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index a6c2da26dd8..040aef0474e 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -130,7 +130,7 @@ func GetPlaylistItems(c *middleware.Context) Response { func GetPlaylistDashboards(c *middleware.Context) Response { playlistId := c.ParamsInt64(":id") - playlists, err := LoadPlaylistDashboards(c.OrgId, c.UserId, playlistId) + playlists, err := LoadPlaylistDashboards(c.OrgId, c.SignedInUser, playlistId) if err != nil { return ApiError(500, "Could not load dashboards", err) } diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index 29a806ce23d..1d059e06be5 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -34,18 +34,18 @@ func populateDashboardsById(dashboardByIds []int64, dashboardIdOrder map[int64]i return result, nil } -func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice { +func populateDashboardsByTag(orgId int64, signedInUser *m.SignedInUser, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice { result := make(dtos.PlaylistDashboardsSlice, 0) if len(dashboardByTag) > 0 { for _, tag := range dashboardByTag { searchQuery := search.Query{ - Title: "", - Tags: []string{tag}, - UserId: userId, - Limit: 100, - IsStarred: false, - OrgId: orgId, + Title: "", + Tags: []string{tag}, + SignedInUser: signedInUser, + Limit: 100, + IsStarred: false, + OrgId: orgId, } if err := bus.Dispatch(&searchQuery); err == nil { @@ -64,7 +64,7 @@ func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string, dashb return result } -func LoadPlaylistDashboards(orgId, userId, playlistId int64) (dtos.PlaylistDashboardsSlice, error) { +func LoadPlaylistDashboards(orgId int64, signedInUser *m.SignedInUser, playlistId int64) (dtos.PlaylistDashboardsSlice, error) { playlistItems, _ := LoadPlaylistItems(playlistId) dashboardByIds := make([]int64, 0) @@ -89,7 +89,7 @@ func LoadPlaylistDashboards(orgId, userId, playlistId int64) (dtos.PlaylistDashb var k, _ = populateDashboardsById(dashboardByIds, dashboardIdOrder) result = append(result, k...) - result = append(result, populateDashboardsByTag(orgId, userId, dashboardByTag, dashboardTagOrder)...) + result = append(result, populateDashboardsByTag(orgId, signedInUser, dashboardByTag, dashboardTagOrder)...) sort.Sort(result) return result, nil diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index faac8c03c62..5f4ec632c4d 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -135,9 +135,24 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { req.Header.Add("Authorization", dsAuth) } - // clear cookie headers + // clear cookie header, except for whitelisted cookies + var keptCookies []*http.Cookie + if proxy.ds.JsonData != nil { + if keepCookies := proxy.ds.JsonData.Get("keepCookies"); keepCookies != nil { + keepCookieNames := keepCookies.MustStringArray() + for _, c := range req.Cookies() { + for _, v := range keepCookieNames { + if c.Name == v { + keptCookies = append(keptCookies, c) + } + } + } + } + } req.Header.Del("Cookie") - req.Header.Del("Set-Cookie") + for _, c := range keptCookies { + req.AddCookie(c) + } // clear X-Forwarded Host/Port/Proto headers req.Header.Del("X-Forwarded-Host") diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 0a900ad3a6d..a7a869b2a9f 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -149,6 +149,58 @@ func TestDSRouteRule(t *testing.T) { }) }) + Convey("When proxying a data source with no keepCookies specified", func() { + plugin := &plugins.DataSourcePlugin{} + + json, _ := simplejson.NewJson([]byte(`{"keepCookies": []}`)) + + ds := &m.DataSource{ + Type: m.DS_GRAPHITE, + Url: "http://graphite:8086", + JsonData: json, + } + + ctx := &middleware.Context{} + proxy := NewDataSourceProxy(ds, plugin, ctx, "") + + requestUrl, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestUrl, Header: make(http.Header)} + cookies := "grafana_user=admin; grafana_remember=99; grafana_sess=11; JSESSION_ID=test" + req.Header.Set("Cookie", cookies) + + proxy.getDirector()(&req) + + Convey("Should clear all cookies", func() { + So(req.Header.Get("Cookie"), ShouldEqual, "") + }) + }) + + Convey("When proxying a data source with keep cookies specified", func() { + plugin := &plugins.DataSourcePlugin{} + + json, _ := simplejson.NewJson([]byte(`{"keepCookies": ["JSESSION_ID"]}`)) + + ds := &m.DataSource{ + Type: m.DS_GRAPHITE, + Url: "http://graphite:8086", + JsonData: json, + } + + ctx := &middleware.Context{} + proxy := NewDataSourceProxy(ds, plugin, ctx, "") + + requestUrl, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestUrl, Header: make(http.Header)} + cookies := "grafana_user=admin; grafana_remember=99; grafana_sess=11; JSESSION_ID=test" + req.Header.Set("Cookie", cookies) + + proxy.getDirector()(&req) + + Convey("Should keep named cookies", func() { + So(req.Header.Get("Cookie"), ShouldEqual, "JSESSION_ID=test") + }) + }) + Convey("When interpolating string", func() { data := templateData{ SecureJsonData: map[string]string{ diff --git a/pkg/api/render.go b/pkg/api/render.go index cab9c81505d..be85da35853 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -21,9 +21,12 @@ func RenderToPng(c *middleware.Context) { Path: c.Params("*") + queryParams, Width: queryReader.Get("width", "800"), Height: queryReader.Get("height", "400"), - OrgId: c.OrgId, Timeout: queryReader.Get("timeout", "60"), + OrgId: c.OrgId, + UserId: c.UserId, + OrgRole: c.OrgRole, Timezone: queryReader.Get("tz", ""), + Encoding: queryReader.Get("encoding", ""), } pngPath, err := renderer.RenderToPng(renderOpts) diff --git a/pkg/api/search.go b/pkg/api/search.go index c68dc51e986..fee062a5599 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -14,27 +14,38 @@ func Search(c *middleware.Context) { tags := c.QueryStrings("tag") starred := c.Query("starred") limit := c.QueryInt("limit") + dashboardType := c.Query("type") if limit == 0 { limit = 1000 } - dbids := make([]int, 0) + dbids := make([]int64, 0) for _, id := range c.QueryStrings("dashboardIds") { - dashboardId, err := strconv.Atoi(id) + dashboardId, err := strconv.ParseInt(id, 10, 64) if err == nil { dbids = append(dbids, dashboardId) } } + folderIds := make([]int64, 0) + for _, id := range c.QueryStrings("folderIds") { + folderId, err := strconv.ParseInt(id, 10, 64) + if err == nil { + folderIds = append(folderIds, folderId) + } + } + searchQuery := search.Query{ Title: query, Tags: tags, - UserId: c.UserId, + SignedInUser: c.SignedInUser, Limit: limit, IsStarred: starred == "true", OrgId: c.OrgId, DashboardIds: dbids, + Type: dashboardType, + FolderIds: folderIds, } err := bus.Dispatch(&searchQuery) diff --git a/pkg/api/team.go b/pkg/api/team.go new file mode 100644 index 00000000000..31e465d3232 --- /dev/null +++ b/pkg/api/team.go @@ -0,0 +1,92 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// POST /api/teams +func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response { + cmd.OrgId = c.OrgId + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamNameTaken { + return ApiError(409, "Team name taken", err) + } + return ApiError(500, "Failed to create Team", err) + } + + return Json(200, &util.DynMap{ + "teamId": cmd.Result.Id, + "message": "Team created", + }) +} + +// PUT /api/teams/:teamId +func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { + cmd.Id = c.ParamsInt64(":teamId") + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamNameTaken { + return ApiError(400, "Team name taken", err) + } + return ApiError(500, "Failed to update Team", err) + } + + return ApiSuccess("Team updated") +} + +// DELETE /api/teams/:teamId +func DeleteTeamById(c *middleware.Context) Response { + if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil { + if err == m.ErrTeamNotFound { + return ApiError(404, "Failed to delete Team. ID not found", nil) + } + return ApiError(500, "Failed to update Team", err) + } + return ApiSuccess("Team deleted") +} + +// GET /api/teams/search +func SearchTeams(c *middleware.Context) Response { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 1000 + } + page := c.QueryInt("page") + if page < 1 { + page = 1 + } + + query := m.SearchTeamsQuery{ + Query: c.Query("query"), + Name: c.Query("name"), + Page: page, + Limit: perPage, + OrgId: c.OrgId, + } + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to search Teams", err) + } + + query.Result.Page = page + query.Result.PerPage = perPage + + return Json(200, query.Result) +} + +// GET /api/teams/:teamId +func GetTeamById(c *middleware.Context) Response { + query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")} + + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrTeamNotFound { + return ApiError(404, "Team not found", err) + } + + return ApiError(500, "Failed to get Team", err) + } + + return Json(200, &query.Result) +} diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go new file mode 100644 index 00000000000..0999c9573a5 --- /dev/null +++ b/pkg/api/team_members.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// GET /api/teams/:teamId/members +func GetTeamMembers(c *middleware.Context) Response { + query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to get Team Members", err) + } + + return Json(200, query.Result) +} + +// POST /api/teams/:teamId/members +func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response { + cmd.TeamId = c.ParamsInt64(":teamId") + cmd.OrgId = c.OrgId + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamMemberAlreadyAdded { + return ApiError(400, "User is already added to this team", err) + } + return ApiError(500, "Failed to add Member to Team", err) + } + + return Json(200, &util.DynMap{ + "message": "Member added to Team", + }) +} + +// DELETE /api/teams/:teamId/members/:userId +func RemoveTeamMember(c *middleware.Context) Response { + if err := bus.Dispatch(&m.RemoveTeamMemberCommand{TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { + return ApiError(500, "Failed to remove Member from Team", err) + } + return ApiSuccess("Team Member removed") +} diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go new file mode 100644 index 00000000000..0bf06d723c8 --- /dev/null +++ b/pkg/api/team_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestTeamApiEndpoint(t *testing.T) { + Convey("Given two teams", t, func() { + mockResult := models.SearchTeamQueryResult{ + Teams: []*models.SearchTeamDto{ + {Name: "team1"}, + {Name: "team2"}, + }, + TotalCount: 2, + } + + Convey("When searching with no parameters", func() { + loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchTeamsQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchTeams + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sentLimit, ShouldEqual, 1000) + So(sendPage, ShouldEqual, 1) + + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2) + So(len(respJSON.Get("teams").MustArray()), ShouldEqual, 2) + }) + }) + + Convey("When searching with page and perpage parameters", func() { + loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchTeamsQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchTeams + sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() + + So(sentLimit, ShouldEqual, 10) + So(sendPage, ShouldEqual, 2) + }) + }) + }) +} diff --git a/pkg/api/user.go b/pkg/api/user.go index 7e8bad91ab0..9a041d30272 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -1,6 +1,7 @@ package api import ( + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -219,7 +220,7 @@ func SearchUsers(c *middleware.Context) Response { return Json(200, query.Result.Users) } -// GET /api/search +// GET /api/users/search func SearchUsersWithPaging(c *middleware.Context) Response { query, err := searchUser(c) if err != nil { @@ -247,6 +248,10 @@ func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) { return nil, err } + for _, user := range query.Result.Users { + user.AvatarUrl = dtos.GetGravatarUrl(user.Email) + } + query.Result.Page = page query.Result.PerPage = perPage diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index edf827ac4ad..ab0e12f2d9f 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -30,7 +30,7 @@ import ( _ "github.com/grafana/grafana/pkg/tsdb/testdata" ) -var version = "4.6.0" +var version = "5.0.0" var commit = "NA" var buildstamp string var build_date string diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index d5980231f0e..25d77557342 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -16,17 +16,22 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) type RenderOpts struct { - Path string - Width string - Height string - Timeout string - OrgId int64 - Timezone string + Path string + Width string + Height string + Timeout string + OrgId int64 + UserId int64 + OrgRole models.RoleType + Timezone string + IsAlertContext bool + Encoding string } var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter") @@ -74,7 +79,11 @@ func RenderToPng(params *RenderOpts) (string, error) { pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20))) pngPath = pngPath + ".png" - renderKey := middleware.AddRenderAuthKey(params.OrgId) + orgRole := params.OrgRole + if params.IsAlertContext { + orgRole = models.ROLE_ADMIN + } + renderKey := middleware.AddRenderAuthKey(params.OrgId, params.UserId, orgRole) defer middleware.RemoveRenderAuthKey(renderKey) timeout, err := strconv.Atoi(params.Timeout) @@ -95,6 +104,10 @@ func RenderToPng(params *RenderOpts) (string, error) { "renderKey=" + renderKey, } + if params.Encoding != "" { + cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", params.Encoding)}, cmdArgs...) + } + cmd := exec.Command(binPath, cmdArgs...) stdout, err := cmd.StdoutPipe() diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 946ebae6b87..259d800f0a9 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -87,7 +87,7 @@ func initContextWithAnonymousUser(ctx *Context) bool { ctx.IsSignedIn = false ctx.AllowAnonymous = true - ctx.SignedInUser = &m.SignedInUser{} + ctx.SignedInUser = &m.SignedInUser{IsAnonymous: true} ctx.OrgRole = m.RoleType(setting.AnonymousOrgRole) ctx.OrgId = orgQuery.Result.Id ctx.OrgName = orgQuery.Result.Name diff --git a/pkg/middleware/render_auth.go b/pkg/middleware/render_auth.go index 3a57660c9bf..d2f9c1b2b1a 100644 --- a/pkg/middleware/render_auth.go +++ b/pkg/middleware/render_auth.go @@ -33,14 +33,15 @@ func initContextWithRenderAuth(ctx *Context) bool { type renderContextFunc func(key string) (string, error) -func AddRenderAuthKey(orgId int64) string { +func AddRenderAuthKey(orgId int64, userId int64, orgRole m.RoleType) string { renderKeysLock.Lock() key := util.GetRandomString(32) renderKeys[key] = &m.SignedInUser{ OrgId: orgId, - OrgRole: m.ROLE_VIEWER, + OrgRole: orgRole, + UserId: userId, } renderKeysLock.Unlock() diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go new file mode 100644 index 00000000000..fa7ad00de7f --- /dev/null +++ b/pkg/models/dashboard_acl.go @@ -0,0 +1,95 @@ +package models + +import ( + "errors" + "time" +) + +type PermissionType int + +const ( + PERMISSION_VIEW PermissionType = 1 << iota + PERMISSION_EDIT + PERMISSION_ADMIN +) + +func (p PermissionType) String() string { + names := map[int]string{ + int(PERMISSION_VIEW): "View", + int(PERMISSION_EDIT): "Edit", + int(PERMISSION_ADMIN): "Admin", + } + return names[int(p)] +} + +// Typed errors +var ( + ErrDashboardAclInfoMissing = errors.New("User id and team id cannot both be empty for a dashboard permission.") + ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.") +) + +// Dashboard ACL model +type DashboardAcl struct { + Id int64 + OrgId int64 + DashboardId int64 + + UserId int64 + TeamId int64 + Role *RoleType // pointer to be nullable + Permission PermissionType + + Created time.Time + Updated time.Time +} + +type DashboardAclInfoDTO struct { + Id int64 `json:"id"` + OrgId int64 `json:"-"` + DashboardId int64 `json:"dashboardId"` + + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + + UserId int64 `json:"userId"` + UserLogin string `json:"userLogin"` + UserEmail string `json:"userEmail"` + TeamId int64 `json:"teamId"` + Team string `json:"team"` + Role *RoleType `json:"role,omitempty"` + Permission PermissionType `json:"permission"` + PermissionName string `json:"permissionName"` +} + +// +// COMMANDS +// + +type UpdateDashboardAclCommand struct { + DashboardId int64 + Items []*DashboardAcl +} + +type SetDashboardAclCommand struct { + DashboardId int64 + OrgId int64 + UserId int64 + TeamId int64 + Permission PermissionType + + Result DashboardAcl +} + +type RemoveDashboardAclCommand struct { + AclId int64 + OrgId int64 +} + +// +// QUERIES +// +type GetDashboardAclInfoListQuery struct { + DashboardId int64 + OrgId int64 + Result []*DashboardAclInfoDTO +} diff --git a/pkg/models/dashboard_acl_test.go b/pkg/models/dashboard_acl_test.go new file mode 100644 index 00000000000..35357ff1cc9 --- /dev/null +++ b/pkg/models/dashboard_acl_test.go @@ -0,0 +1,21 @@ +package models + +import ( + "testing" + + "fmt" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardAclModel(t *testing.T) { + + Convey("When printing a PermissionType", t, func() { + view := PERMISSION_VIEW + printed := fmt.Sprint(view) + + Convey("Should output a friendly name", func() { + So(printed, ShouldEqual, "View") + }) + }) +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index aab6d78db3f..51df2e55a57 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -16,6 +16,7 @@ var ( 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") ) @@ -49,6 +50,9 @@ type Dashboard struct { UpdatedBy int64 CreatedBy int64 + FolderId int64 + IsFolder bool + HasAcl bool Title string Data *simplejson.Json @@ -66,6 +70,15 @@ func NewDashboard(title string) *Dashboard { return dash } +// NewDashboardFolder creates a new dashboard folder +func NewDashboardFolder(title string) *Dashboard { + folder := NewDashboard(title) + folder.Data.Set("schemaVersion", 16) + folder.Data.Set("editable", true) + folder.Data.Set("hideControls", true) + return folder +} + // GetTags turns the tags in data json into go string array func (dash *Dashboard) GetTags() []string { return dash.Data.Get("tags").MustStringArray() @@ -113,6 +126,8 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash.UpdatedBy = userId dash.OrgId = cmd.OrgId dash.PluginId = cmd.PluginId + dash.IsFolder = cmd.IsFolder + dash.FolderId = cmd.FolderId dash.UpdateSlug() return dash } @@ -140,6 +155,8 @@ type SaveDashboardCommand struct { OrgId int64 `json:"-"` RestoredFrom int `json:"-"` PluginId string `json:"-"` + FolderId int64 `json:"folderId"` + IsFolder bool `json:"isFolder"` UpdatedAt time.Time @@ -147,7 +164,7 @@ type SaveDashboardCommand struct { } type DeleteDashboardCommand struct { - Slug string + Id int64 OrgId int64 } diff --git a/pkg/models/dashboards_test.go b/pkg/models/dashboards_test.go index ee16508dc8a..0ec773dfb97 100644 --- a/pkg/models/dashboards_test.go +++ b/pkg/models/dashboards_test.go @@ -28,4 +28,27 @@ func TestDashboardModel(t *testing.T) { }) }) + Convey("Given a new dashboard folder", t, func() { + json := simplejson.New() + json.Set("title", "test dash") + + cmd := &SaveDashboardCommand{Dashboard: json, IsFolder: true} + dash := cmd.GetDashboardModel() + + Convey("Should set IsFolder to true", func() { + So(dash.IsFolder, ShouldBeTrue) + }) + }) + + Convey("Given a child dashboard", t, func() { + json := simplejson.New() + json.Set("title", "test dash") + + cmd := &SaveDashboardCommand{Dashboard: json, FolderId: 1} + dash := cmd.GetDashboardModel() + + Convey("Should set FolderId", func() { + So(dash.FolderId, ShouldEqual, 1) + }) + }) } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 67b081fd1ce..9379625d458 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -18,25 +18,25 @@ var ( type RoleType string const ( - ROLE_VIEWER RoleType = "Viewer" - ROLE_EDITOR RoleType = "Editor" - ROLE_READ_ONLY_EDITOR RoleType = "Read Only Editor" - ROLE_ADMIN RoleType = "Admin" + ROLE_VIEWER RoleType = "Viewer" + ROLE_EDITOR RoleType = "Editor" + ROLE_ADMIN RoleType = "Admin" ) func (r RoleType) IsValid() bool { - return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR + return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR } func (r RoleType) Includes(other RoleType) bool { if r == ROLE_ADMIN { return true } - if r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR { + + if r == ROLE_EDITOR { return other != ROLE_ADMIN } - return r == other + return false } func (r *RoleType) UnmarshalJSON(data []byte) error { @@ -106,6 +106,7 @@ type OrgUserDTO struct { OrgId int64 `json:"orgId"` UserId int64 `json:"userId"` Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` Login string `json:"login"` Role string `json:"role"` LastSeenAt time.Time `json:"lastSeenAt"` diff --git a/pkg/models/team.go b/pkg/models/team.go new file mode 100644 index 00000000000..b9759f059cf --- /dev/null +++ b/pkg/models/team.go @@ -0,0 +1,75 @@ +package models + +import ( + "errors" + "time" +) + +// Typed errors +var ( + ErrTeamNotFound = errors.New("Team not found") + ErrTeamNameTaken = errors.New("Team name is taken") +) + +// Team model +type Team struct { + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// --------------------- +// COMMANDS + +type CreateTeamCommand struct { + Name string `json:"name" binding:"Required"` + OrgId int64 `json:"-"` + + Result Team `json:"-"` +} + +type UpdateTeamCommand struct { + Id int64 + Name string +} + +type DeleteTeamCommand struct { + Id int64 +} + +type GetTeamByIdQuery struct { + Id int64 + Result *Team +} + +type GetTeamsByUserQuery struct { + UserId int64 `json:"userId"` + Result []*Team `json:"teams"` +} + +type SearchTeamsQuery struct { + Query string + Name string + Limit int + Page int + OrgId int64 + + Result SearchTeamQueryResult +} + +type SearchTeamDto struct { + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + MemberCount int64 `json:"memberCount"` +} + +type SearchTeamQueryResult struct { + TotalCount int64 `json:"totalCount"` + Teams []*SearchTeamDto `json:"teams"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go new file mode 100644 index 00000000000..71e5cd4ba12 --- /dev/null +++ b/pkg/models/team_member.go @@ -0,0 +1,55 @@ +package models + +import ( + "errors" + "time" +) + +// Typed errors +var ( + ErrTeamMemberAlreadyAdded = errors.New("User is already added to this team") +) + +// TeamMember model +type TeamMember struct { + Id int64 + OrgId int64 + TeamId int64 + UserId int64 + + Created time.Time + Updated time.Time +} + +// --------------------- +// COMMANDS + +type AddTeamMemberCommand struct { + UserId int64 `json:"userId" binding:"Required"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` +} + +type RemoveTeamMemberCommand struct { + UserId int64 + TeamId int64 +} + +// ---------------------- +// QUERIES + +type GetTeamMembersQuery struct { + TeamId int64 + Result []*TeamMemberDTO +} + +// ---------------------- +// Projections and DTOs + +type TeamMemberDTO struct { + OrgId int64 `json:"orgId"` + TeamId int64 `json:"teamId"` + UserId int64 `json:"userId"` + Email string `json:"email"` + Login string `json:"login"` +} diff --git a/pkg/models/user.go b/pkg/models/user.go index 77938b63a0b..d5b912e0a9c 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -160,7 +160,9 @@ type SignedInUser struct { Name string Email string ApiKeyId int64 + OrgCount int IsGrafanaAdmin bool + IsAnonymous bool HelpFlags1 HelpFlags1 LastSeenAt time.Time } @@ -169,10 +171,28 @@ func (u *SignedInUser) ShouldUpdateLastSeenAt() bool { return u.UserId > 0 && time.Since(u.LastSeenAt) > time.Minute*5 } +func (u *SignedInUser) NameOrFallback() string { + if u.Name != "" { + return u.Name + } else if u.Login != "" { + return u.Login + } else { + return u.Email + } +} + type UpdateUserLastSeenAtCommand struct { UserId int64 } +func (user *SignedInUser) HasRole(role RoleType) bool { + if user.IsGrafanaAdmin { + return true + } + + return user.OrgRole.Includes(role) +} + type UserProfileDTO struct { Id int64 `json:"id"` Email string `json:"email"` @@ -188,6 +208,7 @@ type UserSearchHitDTO struct { Name string `json:"name"` Login string `json:"login"` Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` IsAdmin bool `json:"isAdmin"` LastSeenAt time.Time `json:"lastSeenAt"` LastSeenAtAge string `json:"lastSeenAtAge"` diff --git a/pkg/plugins/dashboard_importer.go b/pkg/plugins/dashboard_importer.go index 1b3e4bac182..bf516818e3c 100644 --- a/pkg/plugins/dashboard_importer.go +++ b/pkg/plugins/dashboard_importer.go @@ -69,6 +69,7 @@ func ImportDashboard(cmd *ImportDashboardCommand) error { UserId: cmd.UserId, Overwrite: cmd.Overwrite, PluginId: cmd.PluginId, + FolderId: dashboard.FolderId, } if err := bus.Dispatch(&saveCmd); err != nil { diff --git a/pkg/plugins/dashboard_importer_test.go b/pkg/plugins/dashboard_importer_test.go index d2897fad1cd..78df94309f8 100644 --- a/pkg/plugins/dashboard_importer_test.go +++ b/pkg/plugins/dashboard_importer_test.go @@ -13,16 +13,9 @@ import ( ) func TestDashboardImport(t *testing.T) { - - Convey("When importing plugin dashboard", t, func() { - setting.Cfg = ini.Empty() - sec, _ := setting.Cfg.NewSection("plugin.test-app") - sec.NewKey("path", "../../tests/test-app") - err := Init() - - So(err, ShouldBeNil) - + pluginScenario("When importing a plugin dashboard", t, func() { var importedDash *m.Dashboard + bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { importedDash = cmd.GetDashboardModel() cmd.Result = importedDash @@ -39,7 +32,7 @@ func TestDashboardImport(t *testing.T) { }, } - err = ImportDashboard(&cmd) + err := ImportDashboard(&cmd) So(err, ShouldBeNil) Convey("should install dashboard", func() { @@ -59,16 +52,16 @@ func TestDashboardImport(t *testing.T) { Convey("When evaling dashboard template", t, func() { template, _ := simplejson.NewJson([]byte(`{ - "__inputs": [ - { - "name": "DS_NAME", - "type": "datasource" - } - ], - "test": { - "prop": "${DS_NAME}" - } - }`)) + "__inputs": [ + { + "name": "DS_NAME", + "type": "datasource" + } + ], + "test": { + "prop": "${DS_NAME}" + } + }`)) evaluator := &DashTemplateEvaluator{ template: template, @@ -92,3 +85,16 @@ func TestDashboardImport(t *testing.T) { }) } + +func pluginScenario(desc string, t *testing.T, fn func()) { + Convey("Given a plugin", t, func() { + setting.Cfg = ini.Empty() + sec, _ := setting.Cfg.NewSection("plugin.test-app") + sec.NewKey("path", "../../tests/test-app") + err := Init() + + So(err, ShouldBeNil) + + Convey(desc, fn) + }) +} diff --git a/pkg/plugins/dashboards.go b/pkg/plugins/dashboards.go index 5d26766b8e5..37e3d8c0076 100644 --- a/pkg/plugins/dashboards.go +++ b/pkg/plugins/dashboards.go @@ -15,6 +15,7 @@ type PluginDashboardInfoDTO struct { Imported bool `json:"imported"` ImportedUri string `json:"importedUri"` Slug string `json:"slug"` + DashboardId int64 `json:"dashboardId"` ImportedRevision int64 `json:"importedRevision"` Revision int64 `json:"revision"` Description string `json:"description"` @@ -60,6 +61,7 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT // find existing dashboard for _, existingDash := range query.Result { if existingDash.Slug == dashboard.Slug { + res.DashboardId = existingDash.Id res.Imported = true res.ImportedUri = "db/" + existingDash.Slug res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1) @@ -74,8 +76,9 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT for _, dash := range query.Result { if _, exists := existingMatches[dash.Id]; !exists { result = append(result, &PluginDashboardInfoDTO{ - Slug: dash.Slug, - Removed: true, + Slug: dash.Slug, + DashboardId: dash.Id, + Removed: true, }) } } diff --git a/pkg/plugins/dashboards_updater.go b/pkg/plugins/dashboards_updater.go index 52a623e73dd..4c40e536d14 100644 --- a/pkg/plugins/dashboards_updater.go +++ b/pkg/plugins/dashboards_updater.go @@ -75,7 +75,7 @@ func syncPluginDashboards(pluginDef *PluginBase, orgId int64) { if dash.Removed { plog.Info("Deleting plugin dashboard", "pluginId", pluginDef.Id, "dashboard", dash.Slug) - deleteCmd := m.DeleteDashboardCommand{OrgId: orgId, Slug: dash.Slug} + deleteCmd := m.DeleteDashboardCommand{OrgId: orgId, Id: dash.DashboardId} if err := bus.Dispatch(&deleteCmd); err != nil { plog.Error("Failed to auto update app dashboard", "pluginId", pluginDef.Id, "error", err) return @@ -124,7 +124,7 @@ func handlePluginStateChanged(event *m.PluginStateChangedEvent) error { return err } else { for _, dash := range query.Result { - deleteCmd := m.DeleteDashboardCommand{OrgId: dash.OrgId, Slug: dash.Slug} + deleteCmd := m.DeleteDashboardCommand{OrgId: dash.OrgId, Id: dash.Id} plog.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug) diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index e92edd2af12..f5663deb8ca 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -75,14 +75,6 @@ func (c *EvalContext) ShouldUpdateAlertState() bool { return c.Rule.State != c.PrevAlertState } -func (c *EvalContext) ShouldSendNotification() bool { - if (c.PrevAlertState == m.AlertStatePending) && (c.Rule.State == m.AlertStateOK) { - return false - } - - return true -} - func (a *EvalContext) GetDurationMs() float64 { return float64(a.EndTime.Nanosecond()-a.StartTime.Nanosecond()) / float64(1000000) } diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go index 5cff2996ca6..019ca1ed01f 100644 --- a/pkg/services/alerting/eval_context_test.go +++ b/pkg/services/alerting/eval_context_test.go @@ -28,21 +28,5 @@ func TestAlertingEvalContext(t *testing.T) { So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) }) }) - - Convey("Should send notifications", func() { - Convey("pending -> ok", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Rule.State = models.AlertStateOK - - So(ctx.ShouldSendNotification(), ShouldBeFalse) - }) - - Convey("ok -> alerting", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.State = models.AlertStateAlerting - - So(ctx.ShouldSendNotification(), ShouldBeTrue) - }) - }) }) } diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go index 79b2f231b41..457e02000fa 100644 --- a/pkg/services/alerting/eval_handler.go +++ b/pkg/services/alerting/eval_handler.go @@ -39,6 +39,11 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) { break } + if i == 0 { + firing = cr.Firing + noDataFound = cr.NoDataFound + } + // calculating Firing based on operator if cr.Operator == "or" { firing = firing || cr.Firing diff --git a/pkg/services/alerting/eval_handler_test.go b/pkg/services/alerting/eval_handler_test.go index cf6422a5250..c942e24818f 100644 --- a/pkg/services/alerting/eval_handler_test.go +++ b/pkg/services/alerting/eval_handler_test.go @@ -36,6 +36,16 @@ func TestAlertingEvaluationHandler(t *testing.T) { So(context.ConditionEvals, ShouldEqual, "true = true") }) + Convey("Show return triggered with single passing condition2", func() { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{&conditionStub{firing: true, operator: "and"}}, + }) + + handler.Eval(context) + So(context.Firing, ShouldEqual, true) + So(context.ConditionEvals, ShouldEqual, "true = true") + }) + Convey("Show return false with not passing asdf", func() { context := NewEvalContext(context.TODO(), &Rule{ Conditions: []Condition{ @@ -131,6 +141,33 @@ func TestAlertingEvaluationHandler(t *testing.T) { So(context.ConditionEvals, ShouldEqual, "[[true OR false] OR true] = true") }) + Convey("Should return false if no condition is firing using OR operator", func() { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: false, operator: "or"}, + &conditionStub{firing: false, operator: "or"}, + &conditionStub{firing: false, operator: "or"}, + }, + }) + + handler.Eval(context) + So(context.Firing, ShouldEqual, false) + So(context.ConditionEvals, ShouldEqual, "[[false OR false] OR false] = false") + }) + + Convey("Should retuasdfrn no data if one condition has nodata", func() { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{operator: "or", noData: false}, + &conditionStub{operator: "or", noData: false}, + &conditionStub{operator: "or", noData: false}, + }, + }) + + handler.Eval(context) + So(context.NoDataFound, ShouldBeFalse) + }) + Convey("Should return no data if one condition has nodata", func() { context := NewEvalContext(context.TODO(), &Rule{ Conditions: []Condition{ diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 0955155575c..18f969ba1b9 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -15,7 +15,7 @@ type Notifier interface { Notify(evalContext *EvalContext) error GetType() string NeedsImage() bool - PassesFilter(rule *Rule) bool + ShouldNotify(evalContext *EvalContext) bool GetNotifierId() int64 GetIsDefault() bool diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index be74d41cc6c..47c9e0c590e 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -24,7 +24,7 @@ type NotifierPlugin struct { } type NotificationService interface { - Send(context *EvalContext) error + SendIfNeeded(context *EvalContext) error } func NewNotificationService() NotificationService { @@ -41,14 +41,12 @@ func newNotificationService() *notificationService { } } -func (n *notificationService) Send(context *EvalContext) error { - notifiers, err := n.getNotifiers(context.Rule.OrgId, context.Rule.Notifications, context) +func (n *notificationService) SendIfNeeded(context *EvalContext) error { + notifiers, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context) if err != nil { return err } - n.log.Info("Sending notifications for", "ruleId", context.Rule.Id, "sent count", len(notifiers)) - if len(notifiers) == 0 { return nil } @@ -67,7 +65,7 @@ func (n *notificationService) sendNotifications(context *EvalContext, notifiers for _, notifier := range notifiers { not := notifier //avoid updating scope variable in go routine - n.log.Info("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault()) + n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault()) metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc() g.Go(func() error { return not.Notify(context) }) } @@ -82,10 +80,11 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) { } renderOpts := &renderer.RenderOpts{ - Width: "800", - Height: "400", - Timeout: "30", - OrgId: context.Rule.OrgId, + Width: "800", + Height: "400", + Timeout: "30", + OrgId: context.Rule.OrgId, + IsAlertContext: true, } if slug, err := context.GetDashboardSlug(); err != nil { @@ -109,7 +108,7 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) { return nil } -func (n *notificationService) getNotifiers(orgId int64, notificationIds []int64, context *EvalContext) (NotifierSlice, error) { +func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, context *EvalContext) (NotifierSlice, error) { query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds} if err := bus.Dispatch(query); err != nil { @@ -121,7 +120,7 @@ func (n *notificationService) getNotifiers(orgId int64, notificationIds []int64, if not, err := n.createNotifierFor(notification); err != nil { return nil, err } else { - if shouldUseNotification(not, context) { + if not.ShouldNotify(context) { result = append(result, not) } } @@ -139,18 +138,6 @@ func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Not return notifierPlugin.Factory(model) } -func shouldUseNotification(notifier Notifier, context *EvalContext) bool { - if !context.Firing { - return true - } - - if context.Error != nil { - return true - } - - return notifier.PassesFilter(context.Rule) -} - type NotifierFactory func(notification *m.AlertNotification) (Notifier, error) var notifierFactories map[string]*NotifierPlugin = make(map[string]*NotifierPlugin) diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go deleted file mode 100644 index 5e378dec890..00000000000 --- a/pkg/services/alerting/notifier_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package alerting - -import ( - "testing" - - "fmt" - - "github.com/grafana/grafana/pkg/models" - m "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" -) - -type FakeNotifier struct { - FakeMatchResult bool -} - -func (fn *FakeNotifier) GetType() string { - return "FakeNotifier" -} - -func (fn *FakeNotifier) NeedsImage() bool { - return true -} - -func (n *FakeNotifier) GetNotifierId() int64 { - return 0 -} - -func (n *FakeNotifier) GetIsDefault() bool { - return false -} - -func (fn *FakeNotifier) Notify(alertResult *EvalContext) error { return nil } - -func (fn *FakeNotifier) PassesFilter(rule *Rule) bool { - return fn.FakeMatchResult -} - -func TestAlertNotificationExtraction(t *testing.T) { - - Convey("Notifier tests", t, func() { - Convey("none firing alerts", func() { - ctx := &EvalContext{ - Firing: false, - Rule: &Rule{ - State: m.AlertStateAlerting, - }, - } - notifier := &FakeNotifier{FakeMatchResult: false} - - So(shouldUseNotification(notifier, ctx), ShouldBeTrue) - }) - - Convey("execution error cannot be ignored", func() { - ctx := &EvalContext{ - Firing: true, - Error: fmt.Errorf("I used to be a programmer just like you"), - Rule: &Rule{ - State: m.AlertStateOK, - }, - } - notifier := &FakeNotifier{FakeMatchResult: false} - - So(shouldUseNotification(notifier, ctx), ShouldBeTrue) - }) - - Convey("firing alert that match", func() { - ctx := &EvalContext{ - Firing: true, - Rule: &Rule{ - State: models.AlertStateAlerting, - }, - } - notifier := &FakeNotifier{FakeMatchResult: true} - - So(shouldUseNotification(notifier, ctx), ShouldBeTrue) - }) - - Convey("firing alert that dont match", func() { - ctx := &EvalContext{ - Firing: true, - Rule: &Rule{State: m.AlertStateOK}, - } - notifier := &FakeNotifier{FakeMatchResult: false} - - So(shouldUseNotification(notifier, ctx), ShouldBeFalse) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/alertmanager.go b/pkg/services/alerting/notifiers/alertmanager.go new file mode 100644 index 00000000000..08f8e8be29c --- /dev/null +++ b/pkg/services/alerting/notifiers/alertmanager.go @@ -0,0 +1,96 @@ +package notifiers + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "prometheus-alertmanager", + Name: "Prometheus Alertmanager", + Description: "Sends alert to Prometheus Alertmanager", + Factory: NewAlertmanagerNotifier, + OptionsTemplate: ` +

Alertmanager settings

+
+ Url + +
+ `, + }) +} + +func NewAlertmanagerNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + url := model.Settings.Get("url").MustString() + if url == "" { + return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} + } + + return &AlertmanagerNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Url: url, + log: log.New("alerting.notifier.prometheus-alertmanager"), + }, nil +} + +type AlertmanagerNotifier struct { + NotifierBase + Url string + log log.Logger +} + +func (this *AlertmanagerNotifier) ShouldNotify(evalContext *alerting.EvalContext) bool { + return evalContext.Rule.State == m.AlertStateAlerting +} + +func (this *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error { + + alerts := make([]interface{}, 0) + for _, match := range evalContext.EvalMatches { + alertJSON := simplejson.New() + alertJSON.Set("startsAt", evalContext.StartTime.UTC().Format(time.RFC3339)) + + if ruleUrl, err := evalContext.GetRuleUrl(); err == nil { + alertJSON.Set("generatorURL", ruleUrl) + } + + if evalContext.Rule.Message != "" { + alertJSON.SetPath([]string{"annotations", "description"}, evalContext.Rule.Message) + } + + tags := make(map[string]string) + if len(match.Tags) == 0 { + tags["metric"] = match.Metric + } else { + for k, v := range match.Tags { + tags[k] = v + } + } + tags["alertname"] = evalContext.Rule.Name + alertJSON.Set("labels", tags) + + alerts = append(alerts, alertJSON) + } + + bodyJSON := simplejson.NewFromAny(alerts) + body, _ := bodyJSON.MarshalJSON() + + cmd := &m.SendWebhookSync{ + Url: this.Url + "/api/v1/alerts", + HttpMethod: "POST", + Body: string(body), + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send alertmanager", "error", err, "alertmanager", this.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/alertmanager_test.go b/pkg/services/alerting/notifiers/alertmanager_test.go new file mode 100644 index 00000000000..3549b536e48 --- /dev/null +++ b/pkg/services/alerting/notifiers/alertmanager_test.go @@ -0,0 +1,47 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAlertmanagerNotifier(t *testing.T) { + Convey("Alertmanager notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "alertmanager", + Type: "alertmanager", + Settings: settingsJSON, + } + + _, err := NewAlertmanagerNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("from settings", func() { + json := `{ "url": "http://127.0.0.1:9093/" }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "alertmanager", + Type: "alertmanager", + Settings: settingsJSON, + } + + not, err := NewAlertmanagerNotifier(model) + alertmanagerNotifier := not.(*AlertmanagerNotifier) + + So(err, ShouldBeNil) + So(alertmanagerNotifier.Url, ShouldEqual, "http://127.0.0.1:9093/") + }) + }) + }) +} diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index dc22e1acaa0..601f8fc24b1 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -2,6 +2,7 @@ package notifiers import ( "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" ) @@ -14,7 +15,7 @@ type NotifierBase struct { } func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase { - uploadImage := model.Get("uploadImage").MustBool(true) + uploadImage := model.Get("uploadImage").MustBool(false) return NotifierBase{ Id: id, @@ -25,7 +26,13 @@ func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model } } -func (n *NotifierBase) PassesFilter(rule *alerting.Rule) bool { +func defaultShouldNotify(context *alerting.EvalContext) bool { + if context.PrevAlertState == context.Rule.State { + return false + } + if (context.PrevAlertState == m.AlertStatePending) && (context.Rule.State == m.AlertStateOK) { + return false + } return true } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go new file mode 100644 index 00000000000..4225e203a3d --- /dev/null +++ b/pkg/services/alerting/notifiers/base_test.go @@ -0,0 +1,32 @@ +package notifiers + +import ( + "context" + "testing" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestBaseNotifier(t *testing.T) { + Convey("Base notifier tests", t, func() { + Convey("should notify", func() { + Convey("pending -> ok", func() { + context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{ + State: m.AlertStatePending, + }) + context.Rule.State = m.AlertStateOK + So(defaultShouldNotify(context), ShouldBeFalse) + }) + + Convey("ok -> alerting", func() { + context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{ + State: m.AlertStateOK, + }) + context.Rule.State = m.AlertStateAlerting + So(defaultShouldNotify(context), ShouldBeTrue) + }) + }) + }) +} diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go index e32b9d34f91..c2029b1173c 100644 --- a/pkg/services/alerting/notifiers/dingding.go +++ b/pkg/services/alerting/notifiers/dingding.go @@ -38,6 +38,10 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error) }, nil } +func (this *DingDingNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + type DingDingNotifier struct { NotifierBase Url string diff --git a/pkg/services/alerting/notifiers/dingding_test.go b/pkg/services/alerting/notifiers/dingding_test.go index 3ca267dbf5b..f89bf6382ce 100644 --- a/pkg/services/alerting/notifiers/dingding_test.go +++ b/pkg/services/alerting/notifiers/dingding_test.go @@ -9,7 +9,7 @@ import ( ) func TestDingDingNotifier(t *testing.T) { - Convey("Line notifier tests", t, func() { + Convey("Dingding notifier tests", t, func() { Convey("empty settings should return error", func() { json := `{ }` @@ -25,10 +25,8 @@ func TestDingDingNotifier(t *testing.T) { }) Convey("settings should trigger incident", func() { - json := ` - { - "url": "https://www.google.com" - }` + json := `{ "url": "https://www.google.com" }` + settingsJSON, _ := simplejson.NewJson([]byte(json)) model := &m.AlertNotification{ Name: "dingding_testing", diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go index 7e8c4b33c0c..f84dc886d83 100644 --- a/pkg/services/alerting/notifiers/email.go +++ b/pkg/services/alerting/notifiers/email.go @@ -58,6 +58,10 @@ func NewEmailNotifier(model *m.AlertNotification) (alerting.Notifier, error) { }, nil } +func (this *EmailNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *EmailNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending alert notification to", "addresses", this.Addresses) diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index f1f63d42a04..b65f25b1422 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -75,6 +75,10 @@ type HipChatNotifier struct { log log.Logger } +func (this *HipChatNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing hipchat notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/kafka.go b/pkg/services/alerting/notifiers/kafka.go index 92f6489106b..e885d44405d 100644 --- a/pkg/services/alerting/notifiers/kafka.go +++ b/pkg/services/alerting/notifiers/kafka.go @@ -57,6 +57,10 @@ type KafkaNotifier struct { log log.Logger } +func (this *KafkaNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error { state := evalContext.Rule.State diff --git a/pkg/services/alerting/notifiers/line.go b/pkg/services/alerting/notifiers/line.go index 4fbaa2d543e..bc0b0c984a4 100644 --- a/pkg/services/alerting/notifiers/line.go +++ b/pkg/services/alerting/notifiers/line.go @@ -51,6 +51,10 @@ type LineNotifier struct { log log.Logger } +func (this *LineNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *LineNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing line notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index aea02465177..1a812f49ca3 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -62,6 +62,10 @@ type OpsGenieNotifier struct { log log.Logger } +func (this *OpsGenieNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *OpsGenieNotifier) Notify(evalContext *alerting.EvalContext) error { var err error diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go index c36dde63943..35f90a9e4b7 100644 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -63,6 +63,10 @@ type PagerdutyNotifier struct { log log.Logger } +func (this *PagerdutyNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { if evalContext.Rule.State == m.AlertStateOK && !this.AutoResolve { diff --git a/pkg/services/alerting/notifiers/pushover.go b/pkg/services/alerting/notifiers/pushover.go index ecb4ed42e3e..6f8d0fb99e6 100644 --- a/pkg/services/alerting/notifiers/pushover.go +++ b/pkg/services/alerting/notifiers/pushover.go @@ -123,6 +123,10 @@ type PushoverNotifier struct { log log.Logger } +func (this *PushoverNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error { ruleUrl, err := evalContext.GetRuleUrl() if err != nil { diff --git a/pkg/services/alerting/notifiers/sensu.go b/pkg/services/alerting/notifiers/sensu.go index 9f77801d458..7a34d51b493 100644 --- a/pkg/services/alerting/notifiers/sensu.go +++ b/pkg/services/alerting/notifiers/sensu.go @@ -71,6 +71,10 @@ type SensuNotifier struct { log log.Logger } +func (this *SensuNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending sensu result") diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index e051a71740a..c5bb9344e30 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -98,6 +98,10 @@ type SlackNotifier struct { log log.Logger } +func (this *SlackNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing slack notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/slack_test.go b/pkg/services/alerting/notifiers/slack_test.go index 13f8c7b48b7..dd82973bc95 100644 --- a/pkg/services/alerting/notifiers/slack_test.go +++ b/pkg/services/alerting/notifiers/slack_test.go @@ -78,7 +78,6 @@ func TestSlackNotifier(t *testing.T) { So(slackNotifier.Mention, ShouldEqual, "@carl") So(slackNotifier.Token, ShouldEqual, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX") }) - }) }) } diff --git a/pkg/services/alerting/notifiers/teams.go b/pkg/services/alerting/notifiers/teams.go index 200a8594428..605b2742325 100644 --- a/pkg/services/alerting/notifiers/teams.go +++ b/pkg/services/alerting/notifiers/teams.go @@ -47,6 +47,10 @@ type TeamsNotifier struct { log log.Logger } +func (this *TeamsNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing teams notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 7fb029e57c8..62da584d019 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -76,6 +76,10 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error) }, nil } +func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending alert notification to", "bot_token", this.BotToken) this.log.Info("Sending alert notification to", "chat_id", this.ChatID) diff --git a/pkg/services/alerting/notifiers/threema.go b/pkg/services/alerting/notifiers/threema.go index e4ffffc9108..b8455dcfbfd 100644 --- a/pkg/services/alerting/notifiers/threema.go +++ b/pkg/services/alerting/notifiers/threema.go @@ -114,6 +114,10 @@ func NewThreemaNotifier(model *m.AlertNotification) (alerting.Notifier, error) { }, nil } +func (this *ThreemaNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (notifier *ThreemaNotifier) Notify(evalContext *alerting.EvalContext) error { notifier.log.Info("Sending alert notification from", "threema_id", notifier.GatewayID) notifier.log.Info("Sending alert notification to", "threema_id", notifier.RecipientID) diff --git a/pkg/services/alerting/notifiers/victorops.go b/pkg/services/alerting/notifiers/victorops.go index 4b4db553cde..a2b770dfd02 100644 --- a/pkg/services/alerting/notifiers/victorops.go +++ b/pkg/services/alerting/notifiers/victorops.go @@ -68,6 +68,10 @@ type VictoropsNotifier struct { log log.Logger } +func (this *VictoropsNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + // Notify sends notification to Victorops via POST to URL endpoint func (this *VictoropsNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing victorops notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/webhook.go b/pkg/services/alerting/notifiers/webhook.go index 4c97ed2b75e..d2d6ec636b7 100644 --- a/pkg/services/alerting/notifiers/webhook.go +++ b/pkg/services/alerting/notifiers/webhook.go @@ -65,6 +65,10 @@ type WebhookNotifier struct { log log.Logger } +func (this *WebhookNotifier) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (this *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending webhook") diff --git a/pkg/services/alerting/notifiers/webhook_test.go b/pkg/services/alerting/notifiers/webhook_test.go index eb25130e4e1..b2d944eb6e9 100644 --- a/pkg/services/alerting/notifiers/webhook_test.go +++ b/pkg/services/alerting/notifiers/webhook_test.go @@ -18,7 +18,7 @@ func TestWebhookNotifier(t *testing.T) { settingsJSON, _ := simplejson.NewJson([]byte(json)) model := &m.AlertNotification{ Name: "ops", - Type: "email", + Type: "webhook", Settings: settingsJSON, } @@ -35,7 +35,7 @@ func TestWebhookNotifier(t *testing.T) { settingsJSON, _ := simplejson.NewJson([]byte(json)) model := &m.AlertNotification{ Name: "ops", - Type: "email", + Type: "webhook", Settings: settingsJSON, } @@ -44,7 +44,7 @@ func TestWebhookNotifier(t *testing.T) { So(err, ShouldBeNil) So(webhookNotifier.Name, ShouldEqual, "ops") - So(webhookNotifier.Type, ShouldEqual, "email") + So(webhookNotifier.Type, ShouldEqual, "webhook") So(webhookNotifier.Url, ShouldEqual, "http://google.com") }) }) diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 448b4ace5bb..8f9deb758a6 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -85,11 +85,9 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { if err := annotationRepo.Save(&item); err != nil { handler.log.Error("Failed to save annotation for new alert state", "error", err) } - - if evalContext.ShouldSendNotification() { - handler.notifier.Send(evalContext) - } } + handler.notifier.SendIfNeeded(evalContext) + return nil } diff --git a/pkg/services/dashboards/dashboards.go b/pkg/services/dashboards/dashboards.go index 24e9f240fd8..4bdba59b18e 100644 --- a/pkg/services/dashboards/dashboards.go +++ b/pkg/services/dashboards/dashboards.go @@ -23,14 +23,12 @@ func SetRepository(rep Repository) { } type SaveDashboardItem struct { - TitleLower string - OrgId int64 - Folder string - UpdatedAt time.Time - UserId int64 - Message string - Overwrite bool - Dashboard *models.Dashboard + OrgId int64 + UpdatedAt time.Time + UserId int64 + Message string + Overwrite bool + Dashboard *models.Dashboard } type DashboardRepository struct{} @@ -57,6 +55,8 @@ func (dr *DashboardRepository) SaveDashboard(json *SaveDashboardItem) (*models.D OrgId: json.OrgId, Overwrite: json.Overwrite, UserId: json.UserId, + FolderId: dashboard.FolderId, + IsFolder: dashboard.IsFolder, } if !json.UpdatedAt.IsZero() { diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go new file mode 100644 index 00000000000..1b664c11385 --- /dev/null +++ b/pkg/services/guardian/guardian.go @@ -0,0 +1,130 @@ +package guardian + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +type DashboardGuardian struct { + user *m.SignedInUser + dashId int64 + orgId int64 + acl []*m.DashboardAclInfoDTO + groups []*m.Team + log log.Logger +} + +func NewDashboardGuardian(dashId int64, orgId int64, user *m.SignedInUser) *DashboardGuardian { + return &DashboardGuardian{ + user: user, + dashId: dashId, + orgId: orgId, + log: log.New("guardians.dashboard"), + } +} + +func (g *DashboardGuardian) CanSave() (bool, error) { + return g.HasPermission(m.PERMISSION_EDIT) +} + +func (g *DashboardGuardian) CanEdit() (bool, error) { + if setting.ViewersCanEdit { + return g.HasPermission(m.PERMISSION_VIEW) + } + + return g.HasPermission(m.PERMISSION_EDIT) +} + +func (g *DashboardGuardian) CanView() (bool, error) { + return g.HasPermission(m.PERMISSION_VIEW) +} + +func (g *DashboardGuardian) CanAdmin() (bool, error) { + return g.HasPermission(m.PERMISSION_ADMIN) +} + +func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) { + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil + } + + acl, err := g.GetAcl() + if err != nil { + return false, err + } + + orgRole := g.user.OrgRole + teamAclItems := []*m.DashboardAclInfoDTO{} + + for _, p := range acl { + // user match + if !g.user.IsAnonymous { + if p.UserId == g.user.UserId && p.Permission >= permission { + return true, nil + } + } + + // role match + if p.Role != nil { + if *p.Role == orgRole && p.Permission >= permission { + return true, nil + } + } + + // remember this rule for later + if p.TeamId > 0 { + teamAclItems = append(teamAclItems, p) + } + } + + // do we have group rules? + if len(teamAclItems) == 0 { + return false, nil + } + + // load groups + teams, err := g.getTeams() + if err != nil { + return false, err + } + + // evalute group rules + for _, p := range acl { + for _, ug := range teams { + if ug.Id == p.TeamId && p.Permission >= permission { + return true, nil + } + } + } + + return false, nil +} + +// Returns dashboard acl +func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { + if g.acl != nil { + return g.acl, nil + } + + query := m.GetDashboardAclInfoListQuery{DashboardId: g.dashId, OrgId: g.orgId} + if err := bus.Dispatch(&query); err != nil { + return nil, err + } + + g.acl = query.Result + return g.acl, nil +} + +func (g *DashboardGuardian) getTeams() ([]*m.Team, error) { + if g.groups != nil { + return g.groups, nil + } + + query := m.GetTeamsByUserQuery{UserId: g.user.UserId} + err := bus.Dispatch(&query) + + g.groups = query.Result + return query.Result, err +} diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 42f232bca82..22fe11b45c1 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -121,6 +121,9 @@ func (fr *fileReader) walkFolder() error { return nil } + // id = 0 indicates ID validation should be avoided before writing to the db. + dash.Dashboard.Id = 0 + cmd := &models.GetDashboardQuery{Slug: dash.Dashboard.Slug} err = bus.Dispatch(cmd) diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 002a56b5f3c..c4c4a67a755 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -1,7 +1,6 @@ package dashboards import ( - "strings" "time" "github.com/grafana/grafana/pkg/components/simplejson" @@ -23,11 +22,9 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das dash := &dashboards.SaveDashboardItem{} dash.Dashboard = models.NewDashboardFromJson(data) - dash.TitleLower = strings.ToLower(dash.Dashboard.Title) dash.UpdatedAt = lastModified dash.Overwrite = true dash.OrgId = cfg.OrgId - dash.Folder = cfg.Folder dash.Dashboard.Data.Set("editable", cfg.Editable) if dash.Dashboard.Title == "" { diff --git a/pkg/services/search/handlers.go b/pkg/services/search/handlers.go index 3a200389d69..247585402ef 100644 --- a/pkg/services/search/handlers.go +++ b/pkg/services/search/handlers.go @@ -12,33 +12,24 @@ func Init() { } func searchHandler(query *Query) error { - hits := make(HitList, 0) - dashQuery := FindPersistedDashboardsQuery{ Title: query.Title, - UserId: query.UserId, + SignedInUser: query.SignedInUser, IsStarred: query.IsStarred, - OrgId: query.OrgId, DashboardIds: query.DashboardIds, + Type: query.Type, + FolderIds: query.FolderIds, + Tags: query.Tags, + Limit: query.Limit, } if err := bus.Dispatch(&dashQuery); err != nil { return err } + hits := make(HitList, 0) hits = append(hits, dashQuery.Result...) - // filter out results with tag filter - if len(query.Tags) > 0 { - filtered := HitList{} - for _, hit := range hits { - if hasRequiredTags(query.Tags, hit.Tags) { - filtered = append(filtered, hit) - } - } - hits = filtered - } - // sort main result array sort.Sort(hits) @@ -52,7 +43,7 @@ func searchHandler(query *Query) error { } // add isStarred info - if err := setIsStarredFlagOnSearchResults(query.UserId, hits); err != nil { + if err := setIsStarredFlagOnSearchResults(query.SignedInUser.UserId, hits); err != nil { return err } @@ -60,25 +51,6 @@ func searchHandler(query *Query) error { return nil } -func stringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} - -func hasRequiredTags(queryTags, hitTags []string) bool { - for _, queryTag := range queryTags { - if !stringInSlice(queryTag, hitTags) { - return false - } - } - - return true -} - func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error { query := m.GetUserStarsQuery{UserId: userId} if err := bus.Dispatch(&query); err != nil { diff --git a/pkg/services/search/handlers_test.go b/pkg/services/search/handlers_test.go index c0ac09f9cd0..fc223b2ef4b 100644 --- a/pkg/services/search/handlers_test.go +++ b/pkg/services/search/handlers_test.go @@ -11,13 +11,15 @@ import ( func TestSearch(t *testing.T) { Convey("Given search query", t, func() { - query := Query{Limit: 2000} + query := Query{Limit: 2000, SignedInUser: &m.SignedInUser{IsGrafanaAdmin: true}} bus.AddHandler("test", func(query *FindPersistedDashboardsQuery) error { query.Result = HitList{ - &Hit{Id: 16, Title: "CCAA", Tags: []string{"BB", "AA"}}, - &Hit{Id: 10, Title: "AABB", Tags: []string{"CC", "AA"}}, - &Hit{Id: 15, Title: "BBAA", Tags: []string{"EE", "AA", "BB"}}, + &Hit{Id: 16, Title: "CCAA", Type: "dash-db", Tags: []string{"BB", "AA"}}, + &Hit{Id: 10, Title: "AABB", Type: "dash-db", Tags: []string{"CC", "AA"}}, + &Hit{Id: 15, Title: "BBAA", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}}, + &Hit{Id: 25, Title: "bbAAa", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}}, + &Hit{Id: 17, Title: "FOLDER", Type: "dash-folder"}, } return nil }) @@ -27,34 +29,29 @@ func TestSearch(t *testing.T) { return nil }) + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{IsGrafanaAdmin: true} + return nil + }) + Convey("That is empty", func() { err := searchHandler(&query) So(err, ShouldBeNil) Convey("should return sorted results", func() { - So(query.Result[0].Title, ShouldEqual, "AABB") - So(query.Result[1].Title, ShouldEqual, "BBAA") - So(query.Result[2].Title, ShouldEqual, "CCAA") + So(query.Result[0].Title, ShouldEqual, "FOLDER") + So(query.Result[1].Title, ShouldEqual, "AABB") + So(query.Result[2].Title, ShouldEqual, "BBAA") + So(query.Result[3].Title, ShouldEqual, "bbAAa") + So(query.Result[4].Title, ShouldEqual, "CCAA") }) Convey("should return sorted tags", func() { - So(query.Result[1].Tags[0], ShouldEqual, "AA") - So(query.Result[1].Tags[1], ShouldEqual, "BB") - So(query.Result[1].Tags[2], ShouldEqual, "EE") + So(query.Result[3].Tags[0], ShouldEqual, "AA") + So(query.Result[3].Tags[1], ShouldEqual, "BB") + So(query.Result[3].Tags[2], ShouldEqual, "EE") }) }) - Convey("That filters by tag", func() { - query.Tags = []string{"BB", "AA"} - err := searchHandler(&query) - So(err, ShouldBeNil) - - Convey("should return correct results", func() { - So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Title, ShouldEqual, "BBAA") - So(query.Result[1].Title, ShouldEqual, "CCAA") - }) - - }) }) } diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go index 159637013f5..cf510ed8462 100644 --- a/pkg/services/search/models.go +++ b/pkg/services/search/models.go @@ -1,37 +1,55 @@ package search +import "strings" +import "github.com/grafana/grafana/pkg/models" + type HitType string const ( - DashHitDB HitType = "dash-db" - DashHitHome HitType = "dash-home" - DashHitJson HitType = "dash-json" - DashHitScripted HitType = "dash-scripted" + DashHitDB HitType = "dash-db" + DashHitHome HitType = "dash-home" + DashHitFolder HitType = "dash-folder" ) type Hit struct { - Id int64 `json:"id"` - Title string `json:"title"` - Uri string `json:"uri"` - Type HitType `json:"type"` - Tags []string `json:"tags"` - IsStarred bool `json:"isStarred"` + Id int64 `json:"id"` + Title string `json:"title"` + Uri string `json:"uri"` + Slug string `json:"slug"` + Type HitType `json:"type"` + Tags []string `json:"tags"` + IsStarred bool `json:"isStarred"` + FolderId int64 `json:"folderId,omitempty"` + FolderTitle string `json:"folderTitle,omitempty"` + FolderSlug string `json:"folderSlug,omitempty"` } type HitList []*Hit -func (s HitList) Len() int { return len(s) } -func (s HitList) Swap(i, j int) { s[i], s[j] = s[j], s[i] } -func (s HitList) Less(i, j int) bool { return s[i].Title < s[j].Title } +func (s HitList) Len() int { return len(s) } +func (s HitList) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s HitList) Less(i, j int) bool { + if s[i].Type == "dash-folder" && s[j].Type == "dash-db" { + return true + } + + if s[i].Type == "dash-db" && s[j].Type == "dash-folder" { + return false + } + + return strings.ToLower(s[i].Title) < strings.ToLower(s[j].Title) +} type Query struct { Title string Tags []string OrgId int64 - UserId int64 + SignedInUser *models.SignedInUser Limit int IsStarred bool - DashboardIds []int + Type string + DashboardIds []int64 + FolderIds []int64 Result HitList } @@ -39,9 +57,14 @@ type Query struct { type FindPersistedDashboardsQuery struct { Title string OrgId int64 - UserId int64 + SignedInUser *models.SignedInUser IsStarred bool - DashboardIds []int + DashboardIds []int64 + Type string + FolderIds []int64 + Tags []string + Limit int + IsBrowse bool Result HitList } diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index daf4f774717..7b27f5b9ca4 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -12,7 +12,7 @@ func TestAlertingDataAccess(t *testing.T) { Convey("Testing Alerting data access", t, func() { InitTestDB(t) - testDash := insertTestDashboard("dashboard with alerts", 1, "alert") + testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert") items := []*m.Alert{ { @@ -192,7 +192,7 @@ func TestAlertingDataAccess(t *testing.T) { err = DeleteDashboard(&m.DeleteDashboardCommand{ OrgId: 1, - Slug: testDash.Slug, + Id: testDash.Id, }) So(err, ShouldBeNil) diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index cdf9d4eb3c7..31a42a7b3b3 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -1,8 +1,6 @@ package sqlstore import ( - "bytes" - "fmt" "time" "github.com/grafana/grafana/pkg/bus" @@ -70,6 +68,11 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { } } + err = setHasAcl(sess, dash) + if err != nil { + return err + } + parentVersion := dash.Version affectedRows := int64(0) @@ -79,14 +82,14 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { dash.Data.Set("version", dash.Version) affectedRows, err = sess.Insert(dash) } else { - dash.Version += 1 + dash.Version++ dash.Data.Set("version", dash.Version) if !cmd.UpdatedAt.IsZero() { dash.Updated = cmd.UpdatedAt } - affectedRows, err = sess.Id(dash.Id).Update(dash) + affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash) } if err != nil { @@ -115,7 +118,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { return m.ErrDashboardNotFound } - // delete existing tabs + // delete existing tags _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) if err != nil { return err @@ -130,13 +133,37 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { } } } - cmd.Result = dash return err }) } +func setHasAcl(sess *DBSession, dash *m.Dashboard) error { + // check if parent has acl + if dash.FolderId > 0 { + var parent m.Dashboard + if hasParent, err := sess.Where("folder_id=?", dash.FolderId).Get(&parent); err != nil { + return err + } else if hasParent && parent.HasAcl { + dash.HasAcl = true + } + } + + // check if dash has its own acl + if dash.Id > 0 { + if res, err := sess.Query("SELECT 1 from dashboard_acl WHERE dashboard_id =?", dash.Id); err != nil { + return err + } else { + if len(res) > 0 { + dash.HasAcl = true + } + } + } + + return nil +} + func GetDashboard(query *m.GetDashboardQuery) error { dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id} has, err := x.Get(&dashboard) @@ -153,64 +180,76 @@ func GetDashboard(query *m.GetDashboardQuery) error { } type DashboardSearchProjection struct { - Id int64 - Title string - Slug string - Term string + Id int64 + Title string + Slug string + Term string + IsFolder bool + FolderId int64 + FolderSlug string + FolderTitle string } -func SearchDashboards(query *search.FindPersistedDashboardsQuery) error { - var sql bytes.Buffer - params := make([]interface{}, 0) - - sql.WriteString(`SELECT - dashboard.id, - dashboard.title, - dashboard.slug, - dashboard_tag.term - FROM dashboard - LEFT OUTER JOIN dashboard_tag on dashboard_tag.dashboard_id = dashboard.id`) - - if query.IsStarred { - sql.WriteString(" INNER JOIN star on star.dashboard_id = dashboard.id") +func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { + limit := query.Limit + if limit == 0 { + limit = 1000 } - sql.WriteString(` WHERE dashboard.org_id=?`) - - params = append(params, query.OrgId) + sb := NewSearchBuilder(query.SignedInUser, limit). + WithTags(query.Tags). + WithDashboardIdsIn(query.DashboardIds) if query.IsStarred { - sql.WriteString(` AND star.user_id=?`) - params = append(params, query.UserId) - } - - if len(query.DashboardIds) > 0 { - sql.WriteString(" AND (") - for i, dashboardId := range query.DashboardIds { - if i != 0 { - sql.WriteString(" OR") - } - - sql.WriteString(" dashboard.id = ?") - params = append(params, dashboardId) - } - sql.WriteString(")") + sb.IsStarred() } if len(query.Title) > 0 { - sql.WriteString(" AND dashboard.title " + dialect.LikeStr() + " ?") - params = append(params, "%"+query.Title+"%") + sb.WithTitle(query.Title) } - sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000")) + if len(query.Type) > 0 { + sb.WithType(query.Type) + } + + if len(query.FolderIds) > 0 { + sb.WithFolderIds(query.FolderIds) + } var res []DashboardSearchProjection - err := x.Sql(sql.String(), params...).Find(&res) + sql, params := sb.ToSql() + err := x.Sql(sql, params...).Find(&res) + if err != nil { + return nil, err + } + + return res, nil +} + +func SearchDashboards(query *search.FindPersistedDashboardsQuery) error { + res, err := findDashboards(query) if err != nil { return err } + makeQueryResult(query, res) + + return nil +} + +func getHitType(item DashboardSearchProjection) search.HitType { + var hitType search.HitType + if item.IsFolder { + hitType = search.DashHitFolder + } else { + hitType = search.DashHitDB + } + + return hitType +} + +func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []DashboardSearchProjection) { query.Result = make([]*search.Hit, 0) hits := make(map[int64]*search.Hit) @@ -218,11 +257,15 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error { hit, exists := hits[item.Id] if !exists { hit = &search.Hit{ - Id: item.Id, - Title: item.Title, - Uri: "db/" + item.Slug, - Type: search.DashHitDB, - Tags: []string{}, + Id: item.Id, + Title: item.Title, + Uri: "db/" + item.Slug, + Slug: item.Slug, + Type: getHitType(item), + FolderId: item.FolderId, + FolderTitle: item.FolderTitle, + FolderSlug: item.FolderSlug, + Tags: []string{}, } query.Result = append(query.Result, hit) hits[item.Id] = hit @@ -231,8 +274,6 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error { hit.Tags = append(hit.Tags, item.Term) } } - - return err } func GetDashboardTags(query *m.GetDashboardTagsQuery) error { @@ -252,7 +293,7 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { return inTransaction(func(sess *DBSession) error { - dashboard := m.Dashboard{Slug: cmd.Slug, OrgId: cmd.OrgId} + dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId} has, err := sess.Get(&dashboard) if err != nil { return err @@ -266,6 +307,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM dashboard WHERE id = ?", "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", "DELETE FROM dashboard_version WHERE dashboard_id = ?", + "DELETE FROM dashboard WHERE folder_id = ?", "DELETE FROM annotation WHERE dashboard_id = ?", } @@ -304,7 +346,7 @@ func GetDashboards(query *m.GetDashboardsQuery) error { func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error { var dashboards = make([]*m.Dashboard, 0) - err := x.Where("org_id=? AND plugin_id=?", query.OrgId, query.PluginId).Find(&dashboards) + err := x.Where("org_id=? AND plugin_id=? AND is_folder=0", query.OrgId, query.PluginId).Find(&dashboards) query.Result = dashboards if err != nil { diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go new file mode 100644 index 00000000000..3ab0361d175 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -0,0 +1,184 @@ +package sqlstore + +import ( + "fmt" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", SetDashboardAcl) + bus.AddHandler("sql", UpdateDashboardAcl) + bus.AddHandler("sql", RemoveDashboardAcl) + bus.AddHandler("sql", GetDashboardAclInfoList) +} + +func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error { + return inTransaction(func(sess *DBSession) error { + // delete existing items + _, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", cmd.DashboardId) + if err != nil { + return err + } + + for _, item := range cmd.Items { + if item.UserId == 0 && item.TeamId == 0 && !item.Role.IsValid() { + return m.ErrDashboardAclInfoMissing + } + + if item.DashboardId == 0 { + return m.ErrDashboardPermissionDashboardEmpty + } + + sess.Nullable("user_id", "team_id") + if _, err := sess.Insert(item); err != nil { + return err + } + } + + // Update dashboard HasAcl flag + dashboard := m.Dashboard{HasAcl: true} + if _, err := sess.Cols("has_acl").Where("id=? OR folder_id=?", cmd.DashboardId, cmd.DashboardId).Update(&dashboard); err != nil { + return err + } + return nil + }) +} + +func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error { + return inTransaction(func(sess *DBSession) error { + if cmd.UserId == 0 && cmd.TeamId == 0 { + return m.ErrDashboardAclInfoMissing + } + + if cmd.DashboardId == 0 { + return m.ErrDashboardPermissionDashboardEmpty + } + + if res, err := sess.Query("SELECT 1 from "+dialect.Quote("dashboard_acl")+" WHERE dashboard_id =? and (team_id=? or user_id=?)", cmd.DashboardId, cmd.TeamId, cmd.UserId); err != nil { + return err + } else if len(res) == 1 { + + entity := m.DashboardAcl{ + Permission: cmd.Permission, + Updated: time.Now(), + } + + if _, err := sess.Cols("updated", "permission").Where("dashboard_id =? and (team_id=? or user_id=?)", cmd.DashboardId, cmd.TeamId, cmd.UserId).Update(&entity); err != nil { + return err + } + + return nil + } + + entity := m.DashboardAcl{ + OrgId: cmd.OrgId, + TeamId: cmd.TeamId, + UserId: cmd.UserId, + Created: time.Now(), + Updated: time.Now(), + DashboardId: cmd.DashboardId, + Permission: cmd.Permission, + } + + cols := []string{"org_id", "created", "updated", "dashboard_id", "permission"} + + if cmd.UserId != 0 { + cols = append(cols, "user_id") + } + + if cmd.TeamId != 0 { + cols = append(cols, "team_id") + } + + _, err := sess.Cols(cols...).Insert(&entity) + if err != nil { + return err + } + + cmd.Result = entity + + // Update dashboard HasAcl flag + dashboard := m.Dashboard{ + HasAcl: true, + } + + if _, err := sess.Cols("has_acl").Where("id=? OR folder_id=?", cmd.DashboardId, cmd.DashboardId).Update(&dashboard); err != nil { + return err + } + + return nil + }) +} + +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=?" + _, err := sess.Exec(rawSQL, cmd.OrgId, cmd.AclId) + if err != nil { + return err + } + + return err + }) +} + +func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { + dashboardFilter := fmt.Sprintf(`IN ( + SELECT %d + UNION + SELECT folder_id from dashboard where id = %d + )`, query.DashboardId, query.DashboardId) + + rawSQL := ` + SELECT + da.id, + da.org_id, + da.dashboard_id, + da.user_id, + da.team_id, + da.permission, + da.role, + da.created, + da.updated, + u.login AS user_login, + u.email AS user_email, + ug.name AS team + FROM` + dialect.Quote("dashboard_acl") + ` as da + LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id + LEFT OUTER JOIN team ug on ug.id = da.team_id + WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? + + -- Also include default permission if has_acl = 0 + + UNION + SELECT + da.id, + da.org_id, + da.dashboard_id, + da.user_id, + da.team_id, + da.permission, + da.role, + da.created, + da.updated, + '' as user_login, + '' as user_email, + '' as team + FROM dashboard_acl as da, + dashboard as dash + LEFT JOIN dashboard folder on dash.folder_id = folder.id + WHERE dash.id = ? AND (dash.has_acl = 0 or folder.has_acl = 0) AND da.dashboard_id = -1 + ` + + query.Result = make([]*m.DashboardAclInfoDTO, 0) + err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result) + + for _, p := range query.Result { + p.PermissionName = p.Permission.String() + } + + return err +} diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/sqlstore/dashboard_acl_test.go new file mode 100644 index 00000000000..bb6363883d6 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_acl_test.go @@ -0,0 +1,236 @@ +package sqlstore + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" +) + +func TestDashboardAclDataAccess(t *testing.T) { + Convey("Testing DB", t, func() { + InitTestDB(t) + Convey("Given a dashboard folder and a user", func() { + currentUser := createUser("viewer", "Viewer", false) + savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") + childDash := insertTestDashboard("2 test dash", 1, savedFolder.Id, false, "prod", "webapp") + + Convey("When adding dashboard permission with userId and teamId set to 0", func() { + err := SetDashboardAcl(&m.SetDashboardAclCommand{ + OrgId: 1, + DashboardId: savedFolder.Id, + Permission: m.PERMISSION_EDIT, + }) + So(err, ShouldEqual, m.ErrDashboardAclInfoMissing) + }) + + Convey("Given dashboard folder with default permissions", func() { + Convey("When reading dashboard acl should include acl for parent folder", func() { + query := m.GetDashboardAclInfoListQuery{DashboardId: childDash.Id, OrgId: 1} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + defaultPermissionsId := -1 + So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER) + So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR) + }) + }) + + Convey("Given dashboard folder permission", func() { + err := SetDashboardAcl(&m.SetDashboardAclCommand{ + OrgId: 1, + UserId: currentUser.Id, + DashboardId: savedFolder.Id, + Permission: m.PERMISSION_EDIT, + }) + So(err, ShouldBeNil) + + Convey("When reading dashboard acl should include acl for parent folder", func() { + query := m.GetDashboardAclInfoListQuery{DashboardId: childDash.Id, OrgId: 1} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].DashboardId, ShouldEqual, savedFolder.Id) + }) + + Convey("Given child dashboard permission", func() { + err := SetDashboardAcl(&m.SetDashboardAclCommand{ + OrgId: 1, + UserId: currentUser.Id, + DashboardId: childDash.Id, + Permission: m.PERMISSION_EDIT, + }) + So(err, ShouldBeNil) + + Convey("When reading dashboard acl should include acl for parent folder and child", func() { + query := m.GetDashboardAclInfoListQuery{OrgId: 1, DashboardId: childDash.Id} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, savedFolder.Id) + So(query.Result[1].DashboardId, ShouldEqual, childDash.Id) + }) + }) + }) + + Convey("Given child dashboard permission in folder with no permissions", func() { + err := SetDashboardAcl(&m.SetDashboardAclCommand{ + OrgId: 1, + UserId: currentUser.Id, + DashboardId: childDash.Id, + Permission: m.PERMISSION_EDIT, + }) + So(err, ShouldBeNil) + + Convey("When reading dashboard acl should include default acl for parent folder and the child acl", func() { + query := m.GetDashboardAclInfoListQuery{OrgId: 1, DashboardId: childDash.Id} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + defaultPermissionsId := -1 + So(len(query.Result), ShouldEqual, 3) + So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER) + So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR) + So(query.Result[2].DashboardId, ShouldEqual, childDash.Id) + }) + }) + + Convey("Should be able to add dashboard permission", func() { + setDashAclCmd := m.SetDashboardAclCommand{ + OrgId: 1, + UserId: currentUser.Id, + DashboardId: savedFolder.Id, + Permission: m.PERMISSION_EDIT, + } + + err := SetDashboardAcl(&setDashAclCmd) + So(err, ShouldBeNil) + + So(setDashAclCmd.Result.Id, ShouldEqual, 3) + + q1 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} + err = GetDashboardAclInfoList(q1) + So(err, ShouldBeNil) + + So(q1.Result[0].DashboardId, ShouldEqual, savedFolder.Id) + So(q1.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT) + So(q1.Result[0].PermissionName, ShouldEqual, "Edit") + So(q1.Result[0].UserId, ShouldEqual, currentUser.Id) + So(q1.Result[0].UserLogin, ShouldEqual, currentUser.Login) + So(q1.Result[0].UserEmail, ShouldEqual, currentUser.Email) + So(q1.Result[0].Id, ShouldEqual, setDashAclCmd.Result.Id) + + Convey("Should update hasAcl field to true for dashboard folder and its children", func() { + q2 := &m.GetDashboardsQuery{DashboardIds: []int64{savedFolder.Id, childDash.Id}} + err := GetDashboards(q2) + So(err, ShouldBeNil) + So(q2.Result[0].HasAcl, ShouldBeTrue) + So(q2.Result[1].HasAcl, ShouldBeTrue) + }) + + Convey("Should be able to update an existing permission", func() { + err := SetDashboardAcl(&m.SetDashboardAclCommand{ + OrgId: 1, + UserId: 1, + DashboardId: savedFolder.Id, + Permission: m.PERMISSION_ADMIN, + }) + + So(err, ShouldBeNil) + + q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} + err = GetDashboardAclInfoList(q3) + So(err, ShouldBeNil) + So(len(q3.Result), ShouldEqual, 1) + So(q3.Result[0].DashboardId, ShouldEqual, savedFolder.Id) + So(q3.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) + So(q3.Result[0].UserId, ShouldEqual, 1) + + }) + + Convey("Should be able to delete an existing permission", func() { + err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{ + OrgId: 1, + AclId: setDashAclCmd.Result.Id, + }) + + So(err, ShouldBeNil) + + q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} + err = GetDashboardAclInfoList(q3) + So(err, ShouldBeNil) + So(len(q3.Result), ShouldEqual, 0) + }) + }) + + Convey("Given a team", func() { + group1 := m.CreateTeamCommand{Name: "group1 name", OrgId: 1} + err := CreateTeam(&group1) + So(err, ShouldBeNil) + + Convey("Should be able to add a user permission for a team", func() { + setDashAclCmd := m.SetDashboardAclCommand{ + OrgId: 1, + TeamId: group1.Result.Id, + DashboardId: savedFolder.Id, + Permission: m.PERMISSION_EDIT, + } + + err := SetDashboardAcl(&setDashAclCmd) + So(err, ShouldBeNil) + + q1 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} + err = GetDashboardAclInfoList(q1) + So(err, ShouldBeNil) + So(q1.Result[0].DashboardId, ShouldEqual, savedFolder.Id) + So(q1.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT) + So(q1.Result[0].TeamId, ShouldEqual, group1.Result.Id) + + Convey("Should be able to delete an existing permission for a team", func() { + err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{ + OrgId: 1, + AclId: setDashAclCmd.Result.Id, + }) + + So(err, ShouldBeNil) + q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} + err = GetDashboardAclInfoList(q3) + So(err, ShouldBeNil) + So(len(q3.Result), ShouldEqual, 0) + }) + }) + + Convey("Should be able to update an existing permission for a team", func() { + err := SetDashboardAcl(&m.SetDashboardAclCommand{ + OrgId: 1, + TeamId: group1.Result.Id, + DashboardId: savedFolder.Id, + Permission: m.PERMISSION_ADMIN, + }) + So(err, ShouldBeNil) + + q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} + err = GetDashboardAclInfoList(q3) + So(err, ShouldBeNil) + So(len(q3.Result), ShouldEqual, 1) + So(q3.Result[0].DashboardId, ShouldEqual, savedFolder.Id) + So(q3.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) + So(q3.Result[0].TeamId, ShouldEqual, group1.Result.Id) + }) + + }) + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index a055500592b..a552bd0546a 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -3,44 +3,39 @@ package sqlstore import ( "testing" + "github.com/go-xorm/xorm" . "github.com/smartystreets/goconvey/convey" - "github.com/gosimple/slug" "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" ) -func insertTestDashboard(title string, orgId int64, tags ...interface{}) *m.Dashboard { - cmd := m.SaveDashboardCommand{ - OrgId: orgId, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": title, - "tags": tags, - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - - return cmd.Result -} - func TestDashboardDataAccess(t *testing.T) { + var x *xorm.Engine Convey("Testing DB", t, func() { - InitTestDB(t) + x = InitTestDB(t) Convey("Given saved dashboard", func() { - savedDash := insertTestDashboard("test dash 23", 1, "prod", "webapp") - insertTestDashboard("test dash 45", 1, "prod") - insertTestDashboard("test dash 67", 1, "prod", "webapp") + savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") + savedDash := insertTestDashboard("test dash 23", 1, savedFolder.Id, false, "prod", "webapp") + insertTestDashboard("test dash 45", 1, savedFolder.Id, false, "prod") + insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp") Convey("Should return dashboard model", func() { So(savedDash.Title, ShouldEqual, "test dash 23") So(savedDash.Slug, ShouldEqual, "test-dash-23") So(savedDash.Id, ShouldNotEqual, 0) + So(savedDash.IsFolder, ShouldBeFalse) + So(savedDash.FolderId, 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) }) Convey("Should be able to get dashboard", func() { @@ -54,15 +49,14 @@ func TestDashboardDataAccess(t *testing.T) { So(query.Result.Title, ShouldEqual, "test dash 23") So(query.Result.Slug, ShouldEqual, "test-dash-23") + So(query.Result.IsFolder, ShouldBeFalse) }) Convey("Should be able to delete dashboard", func() { - insertTestDashboard("delete me", 1, "delete this") - - dashboardSlug := slug.Make("delete me") + dash := insertTestDashboard("delete me", 1, 0, false, "delete this") err := DeleteDashboard(&m.DeleteDashboardCommand{ - Slug: dashboardSlug, + Id: dash.Id, OrgId: 1, }) @@ -102,10 +96,11 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldNotBeNil) }) - Convey("Should be able to search for dashboard", func() { + Convey("Should be able to search for dashboard folder", func() { query := search.FindPersistedDashboardsQuery{ - Title: "test dash 23", - OrgId: 1, + Title: "1 test dash folder", + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgId: 1}, } err := SearchDashboards(&query) @@ -113,14 +108,29 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 1) hit := query.Result[0] - So(len(hit.Tags), ShouldEqual, 2) + So(hit.Type, ShouldEqual, search.DashHitFolder) + }) + + Convey("Should be able to search for a dashboard folder's children", func() { + query := search.FindPersistedDashboardsQuery{ + OrgId: 1, + FolderIds: []int64{savedFolder.Id}, + SignedInUser: &m.SignedInUser{OrgId: 1}, + } + + err := SearchDashboards(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + hit := query.Result[0] + So(hit.Id, ShouldEqual, savedDash.Id) }) Convey("Should be able to search for dashboard by dashboard ids", func() { Convey("should be able to find two dashboards by id", func() { query := search.FindPersistedDashboardsQuery{ - DashboardIds: []int{1, 2}, - OrgId: 1, + DashboardIds: []int64{2, 3}, + SignedInUser: &m.SignedInUser{OrgId: 1}, } err := SearchDashboards(&query) @@ -137,8 +147,8 @@ func TestDashboardDataAccess(t *testing.T) { Convey("DashboardIds that does not exists should not cause errors", func() { query := search.FindPersistedDashboardsQuery{ - DashboardIds: []int{1000}, - OrgId: 1, + DashboardIds: []int64{1000}, + SignedInUser: &m.SignedInUser{OrgId: 1}, } err := SearchDashboards(&query) @@ -161,6 +171,63 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldNotBeNil) }) + Convey("Should be able to update dashboard and remove folderId", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": 1, + "title": "folderId", + "tags": []interface{}{}, + }), + Overwrite: true, + 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": 1, + "title": "folderId", + "tags": []interface{}{}, + }), + FolderId: 0, + Overwrite: true, + } + + err = SaveDashboard(&cmd) + So(err, ShouldBeNil) + + query := m.GetDashboardQuery{ + Slug: cmd.Result.Slug, + OrgId: 1, + } + + err = GetDashboard(&query) + So(err, ShouldBeNil) + So(query.Result.FolderId, ShouldEqual, 0) + }) + + Convey("Should be able to delete a dashboard folder and its children", func() { + deleteCmd := &m.DeleteDashboardCommand{Id: savedFolder.Id} + err := DeleteDashboard(deleteCmd) + So(err, ShouldBeNil) + + query := search.FindPersistedDashboardsQuery{ + OrgId: 1, + FolderIds: []int64{savedFolder.Id}, + SignedInUser: &m.SignedInUser{}, + } + + err = SearchDashboards(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 0) + }) + Convey("Should be able to get dashboard tags", func() { query := m.GetDashboardTagsQuery{OrgId: 1} @@ -171,7 +238,7 @@ func TestDashboardDataAccess(t *testing.T) { }) Convey("Given two dashboards, one is starred dashboard by user 10, other starred by user 1", func() { - starredDash := insertTestDashboard("starred dash", 1) + starredDash := insertTestDashboard("starred dash", 1, 0, false) StarDashboard(&m.StarDashboardCommand{ DashboardId: starredDash.Id, UserId: 10, @@ -183,7 +250,7 @@ func TestDashboardDataAccess(t *testing.T) { }) Convey("Should be able to search for starred dashboards", func() { - query := search.FindPersistedDashboardsQuery{OrgId: 1, UserId: 10, IsStarred: true} + query := search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: 10, OrgId: 1}, IsStarred: true} err := SearchDashboards(&query) So(err, ShouldBeNil) @@ -192,5 +259,307 @@ func TestDashboardDataAccess(t *testing.T) { }) }) }) + + Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() { + folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") + dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp") + childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp") + insertTestDashboard("test dash 45", 1, folder.Id, false, "prod") + + currentUser := createUser("viewer", "Viewer", false) + + Convey("and no acls are set", func() { + Convey("should return all dashboards", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and acl is set for dashboard folder", func() { + var otherUser int64 = 999 + updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) + + Convey("should not return folder", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + }) + + Convey("when the user is given permission", func() { + updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT) + + Convey("should be able to access folder", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("when the user is an admin", func() { + Convey("should be able to access folder", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{ + UserId: currentUser.Id, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + }, + OrgId: 1, + DashboardIds: []int64{folder.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + }) + + Convey("and acl is set for dashboard child and folder has all permissions removed", func() { + var otherUser int64 = 999 + aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) + removeAcl(aclId) + updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT) + + Convey("should not return folder or child", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + }) + + Convey("when the user is given permission to child", func() { + updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT) + + Convey("should be able to search for child dashboard but not folder", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, childDash.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("when the user is an admin", func() { + Convey("should be able to search for child dash and folder", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{ + UserId: currentUser.Id, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + }, + OrgId: 1, + DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 3) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, childDash.Id) + So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) + }) + }) + }) + }) + + Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() { + folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod") + folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod") + dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod") + childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod") + childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod") + + currentUser := createUser("viewer", "Viewer", false) + var rootFolderId int64 = 0 + + Convey("and one folder is expanded, the other collapsed", func() { + Convey("should return dashboards in root and expanded folder", func() { + query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 4) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[1].Id, ShouldEqual, folder2.Id) + So(query.Result[2].Id, ShouldEqual, childDash1.Id) + So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and acl is set for one dashboard folder", func() { + var otherUser int64 = 999 + updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT) + + Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() { + movedDash := moveDashboard(1, childDash2.Data, folder1.Id) + So(movedDash.HasAcl, ShouldBeTrue) + + Convey("should not return folder with acl or its children", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + OrgId: 1, + DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() { + movedDash := moveDashboard(1, childDash1.Data, folder2.Id) + So(movedDash.HasAcl, ShouldBeFalse) + + Convey("should return folder without acl and its children", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + OrgId: 1, + DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 4) + So(query.Result[0].Id, ShouldEqual, folder2.Id) + So(query.Result[1].Id, ShouldEqual, childDash1.Id) + So(query.Result[2].Id, ShouldEqual, childDash2.Id) + So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and a dashboard with an acl is moved to the folder without an acl", func() { + updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT) + movedDash := moveDashboard(1, childDash1.Data, folder2.Id) + So(movedDash.HasAcl, ShouldBeTrue) + + Convey("should return folder without acl but not the dashboard with acl", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + OrgId: 1, + DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 3) + So(query.Result[0].Id, ShouldEqual, folder2.Id) + So(query.Result[1].Id, ShouldEqual, childDash2.Id) + So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) + }) + }) + }) + }) + + Convey("Given a plugin with imported dashboards", func() { + pluginId := "test-app" + + appFolder := insertTestDashboardForPlugin("app-test", 1, 0, true, pluginId) + insertTestDashboardForPlugin("app-dash1", 1, appFolder.Id, false, pluginId) + insertTestDashboardForPlugin("app-dash2", 1, appFolder.Id, false, pluginId) + + Convey("Should return imported dashboard", func() { + query := m.GetDashboardsByPluginIdQuery{ + PluginId: pluginId, + OrgId: 1, + } + + err := GetDashboardsByPluginId(&query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + }) + }) }) } + +func insertTestDashboard(title string, orgId int64, folderId int64, isFolder bool, tags ...interface{}) *m.Dashboard { + cmd := m.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + + return cmd.Result +} + +func insertTestDashboardForPlugin(title string, orgId int64, folderId int64, isFolder bool, pluginId string) *m.Dashboard { + cmd := m.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + }), + PluginId: pluginId, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + + return cmd.Result +} + +func createUser(name string, role string, isAdmin bool) m.User { + setting.AutoAssignOrg = true + setting.AutoAssignOrgRole = role + + currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin} + err := CreateUser(¤tUserCmd) + So(err, ShouldBeNil) + + q1 := m.GetUserOrgListQuery{UserId: currentUserCmd.Result.Id} + GetUserOrgList(&q1) + So(q1.Result[0].Role, ShouldEqual, role) + + return currentUserCmd.Result +} + +func updateTestDashboardWithAcl(dashId int64, userId int64, permissions m.PermissionType) int64 { + cmd := &m.SetDashboardAclCommand{ + OrgId: 1, + UserId: userId, + DashboardId: dashId, + Permission: permissions, + } + + err := SetDashboardAcl(cmd) + So(err, ShouldBeNil) + + return cmd.Result.Id +} + +func removeAcl(aclId int64) { + err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{AclId: aclId, OrgId: 1}) + So(err, ShouldBeNil) +} + +func moveDashboard(orgId int64, dashboard *simplejson.Json, newFolderId int64) *m.Dashboard { + cmd := m.SaveDashboardCommand{ + OrgId: orgId, + FolderId: newFolderId, + Dashboard: dashboard, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + + return cmd.Result +} diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go index 484a7e281dc..49c35397094 100644 --- a/pkg/services/sqlstore/dashboard_version.go +++ b/pkg/services/sqlstore/dashboard_version.go @@ -36,6 +36,10 @@ func GetDashboardVersion(query *m.GetDashboardVersionQuery) error { // GetDashboardVersions gets all dashboard versions for the given dashboard ID. func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error { + if query.Limit == 0 { + query.Limit = 1000 + } + err := x.Table("dashboard_version"). Select(`dashboard_version.id, dashboard_version.dashboard_id, diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index a27d4385637..6ed37cd6904 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -29,7 +29,7 @@ func TestGetDashboardVersion(t *testing.T) { InitTestDB(t) Convey("Get a Dashboard ID and version ID", func() { - savedDash := insertTestDashboard("test dash 26", 1, "diff") + savedDash := insertTestDashboard("test dash 26", 1, 0, false, "diff") query := m.GetDashboardVersionQuery{ DashboardId: savedDash.Id, @@ -70,7 +70,7 @@ func TestGetDashboardVersion(t *testing.T) { func TestGetDashboardVersions(t *testing.T) { Convey("Testing dashboard versions retrieval", t, func() { InitTestDB(t) - savedDash := insertTestDashboard("test dash 43", 1, "diff-all") + savedDash := insertTestDashboard("test dash 43", 1, 0, false, "diff-all") Convey("Get all versions for a given Dashboard ID", func() { query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1} @@ -110,7 +110,7 @@ func TestDeleteExpiredVersions(t *testing.T) { versionsToWrite := 10 setting.DashboardVersionsToKeep = versionsToKeep - savedDash := insertTestDashboard("test dash 53", 1, "diff-all") + savedDash := insertTestDashboard("test dash 53", 1, 0, false, "diff-all") for i := 0; i < versionsToWrite-1; i++ { updateTestDashboard(savedDash, map[string]interface{}{ "tags": "different-tag", diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index 135867cf0f5..e6f0114ab4d 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" ) -func InitTestDB(t *testing.T) { +func InitTestDB(t *testing.T) *xorm.Engine { x, err := xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr) //x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr) //x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) @@ -27,6 +27,8 @@ func InitTestDB(t *testing.T) { if err := SetEngine(x); err != nil { t.Fatal(err) } + + return x } type Test struct { diff --git a/pkg/services/sqlstore/migrations/dashboard_acl.go b/pkg/services/sqlstore/migrations/dashboard_acl.go new file mode 100644 index 00000000000..cc3b813c12f --- /dev/null +++ b/pkg/services/sqlstore/migrations/dashboard_acl.go @@ -0,0 +1,52 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addDashboardAclMigrations(mg *Migrator) { + dashboardAclV1 := Table{ + Name: "dashboard_acl", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: DB_BigInt}, + {Name: "dashboard_id", Type: DB_BigInt}, + {Name: "user_id", Type: DB_BigInt, Nullable: true}, + {Name: "team_id", Type: DB_BigInt, Nullable: true}, + {Name: "permission", Type: DB_SmallInt, Default: "4"}, + {Name: "role", Type: DB_Varchar, Length: 20, Nullable: true}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"dashboard_id"}}, + {Cols: []string{"dashboard_id", "user_id"}, Type: UniqueIndex}, + {Cols: []string{"dashboard_id", "team_id"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create dashboard acl table", NewAddTableMigration(dashboardAclV1)) + + //------- indexes ------------------ + mg.AddMigration("add index dashboard_acl_dashboard_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[0])) + mg.AddMigration("add unique index dashboard_acl_dashboard_id_user_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[1])) + mg.AddMigration("add unique index dashboard_acl_dashboard_id_team_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[2])) + + const rawSQL = ` +INSERT INTO dashboard_acl + ( + org_id, + dashboard_id, + permission, + role, + created, + updated + ) + VALUES + (-1,-1, 1,'Viewer','2017-06-20','2017-06-20'), + (-1,-1, 2,'Editor','2017-06-20','2017-06-20') + ` + + mg.AddMigration("save default acl rules in dashboard_acl table", new(RawSqlMigration). + Sqlite(rawSQL). + Postgres(rawSQL). + Mysql(rawSQL)) +} diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index ee0cc1d893f..4f1602be931 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -136,4 +136,18 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Update dashboard_tag table charset", NewTableCharsetMigration("dashboard_tag", []*Column{ {Name: "term", Type: DB_NVarchar, Length: 50, Nullable: false}, })) + + // add column to store folder_id for dashboard folder structure + mg.AddMigration("Add column folder_id in dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "folder_id", Type: DB_BigInt, Nullable: false, Default: "0", + })) + + mg.AddMigration("Add column isFolder in dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "is_folder", Type: DB_Bool, Nullable: false, Default: "0", + })) + + // add column to flag if dashboard has an ACL + mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0", + })) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 4984ff18592..8e9268779ef 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -26,6 +26,8 @@ func AddMigrations(mg *Migrator) { addAnnotationMig(mg) addTestDataMigrations(mg) addDashboardVersionMigration(mg) + addTeamMigrations(mg) + addDashboardAclMigrations(mg) addTagMigration(mg) } diff --git a/pkg/services/sqlstore/migrations/org_mig.go b/pkg/services/sqlstore/migrations/org_mig.go index 12e0a04256a..cf9b19f6f5b 100644 --- a/pkg/services/sqlstore/migrations/org_mig.go +++ b/pkg/services/sqlstore/migrations/org_mig.go @@ -83,4 +83,10 @@ func addOrgMigrations(mg *Migrator) { mg.AddMigration("Update org_user table charset", NewTableCharsetMigration("org_user", []*Column{ {Name: "role", Type: DB_NVarchar, Length: 20}, })) + + const migrateReadOnlyViewersToViewers = `UPDATE org_user SET role = 'Viewer' WHERE role = 'Read Only Editor'` + mg.AddMigration("Migrate all Read Only Viewers to Viewers", new(RawSqlMigration). + Sqlite(migrateReadOnlyViewersToViewers). + Postgres(migrateReadOnlyViewersToViewers). + Mysql(migrateReadOnlyViewersToViewers)) } diff --git a/pkg/services/sqlstore/migrations/team_mig.go b/pkg/services/sqlstore/migrations/team_mig.go new file mode 100644 index 00000000000..cc479097f9b --- /dev/null +++ b/pkg/services/sqlstore/migrations/team_mig.go @@ -0,0 +1,48 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addTeamMigrations(mg *Migrator) { + teamV1 := Table{ + Name: "team", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "org_id", Type: DB_BigInt}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"org_id"}}, + {Cols: []string{"org_id", "name"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create team table", NewAddTableMigration(teamV1)) + + //------- indexes ------------------ + mg.AddMigration("add index team.org_id", NewAddIndexMigration(teamV1, teamV1.Indices[0])) + mg.AddMigration("add unique index team_org_id_name", NewAddIndexMigration(teamV1, teamV1.Indices[1])) + + teamMemberV1 := Table{ + Name: "team_member", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: DB_BigInt}, + {Name: "team_id", Type: DB_BigInt}, + {Name: "user_id", Type: DB_BigInt}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"org_id"}}, + {Cols: []string{"org_id", "team_id", "user_id"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create team member table", NewAddTableMigration(teamMemberV1)) + + //------- indexes ------------------ + mg.AddMigration("add index team_member.org_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[0])) + mg.AddMigration("add unique index team_member_org_id_team_id_user_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[1])) +} diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index e7c718fc9a8..59d96c4f8ca 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -154,6 +154,57 @@ func TestAccountDataAccess(t *testing.T) { So(err, ShouldEqual, m.ErrLastOrgAdmin) }) + Convey("Given an org user with dashboard permissions", func() { + ac3cmd := m.CreateUserCommand{Login: "ac3", Email: "ac3@test.com", Name: "ac3 name", IsAdmin: false} + err := CreateUser(&ac3cmd) + So(err, ShouldBeNil) + ac3 := ac3cmd.Result + + orgUserCmd := m.AddOrgUserCommand{ + OrgId: ac1.OrgId, + UserId: ac3.Id, + Role: m.ROLE_VIEWER, + } + + err = AddOrgUser(&orgUserCmd) + So(err, ShouldBeNil) + + query := m.GetOrgUsersQuery{OrgId: ac1.OrgId} + err = GetOrgUsers(&query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 3) + + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: ac1.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT}) + So(err, ShouldBeNil) + + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 2, OrgId: ac3.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT}) + So(err, ShouldBeNil) + + Convey("When org user is deleted", func() { + cmdRemove := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac3.Id} + err := RemoveOrgUser(&cmdRemove) + So(err, ShouldBeNil) + + Convey("Should remove dependent permissions for deleted org user", func() { + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: ac1.OrgId} + err = GetDashboardAclInfoList(permQuery) + So(err, ShouldBeNil) + + So(len(permQuery.Result), ShouldEqual, 0) + }) + + Convey("Should not remove dashboard permissions for same user in another org", func() { + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 2, OrgId: ac3.OrgId} + err = GetDashboardAclInfoList(permQuery) + So(err, ShouldBeNil) + + So(len(permQuery.Result), ShouldEqual, 1) + So(permQuery.Result[0].OrgId, ShouldEqual, ac3.OrgId) + So(permQuery.Result[0].UserId, ShouldEqual, ac3.Id) + }) + + }) + }) }) }) }) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 60800d1cb13..2c2a51fd362 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -88,10 +88,17 @@ func GetOrgUsers(query *m.GetOrgUsersQuery) error { func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { return inTransaction(func(sess *DBSession) error { - var rawSql = "DELETE FROM org_user WHERE org_id=? and user_id=?" - _, err := sess.Exec(rawSql, cmd.OrgId, cmd.UserId) - if err != nil { - return err + deletes := []string{ + "DELETE FROM org_user WHERE org_id=? and user_id=?", + "DELETE FROM dashboard_acl WHERE org_id=? and user_id = ?", + "DELETE FROM team_member WHERE org_id=? and user_id = ?", + } + + for _, sql := range deletes { + _, err := sess.Exec(sql, cmd.OrgId, cmd.UserId) + if err != nil { + return err + } } return validateOneAdminLeftInOrg(cmd.OrgId, sess) diff --git a/pkg/services/sqlstore/search_builder.go b/pkg/services/sqlstore/search_builder.go new file mode 100644 index 00000000000..6a5e8e60b54 --- /dev/null +++ b/pkg/services/sqlstore/search_builder.go @@ -0,0 +1,214 @@ +package sqlstore + +import ( + "bytes" + "strings" + + m "github.com/grafana/grafana/pkg/models" +) + +// SearchBuilder is a builder/object mother that builds a dashboard search query +type SearchBuilder struct { + tags []string + isStarred bool + limit int + signedInUser *m.SignedInUser + whereDashboardIdsIn []int64 + whereTitle string + whereTypeFolder bool + whereTypeDash bool + whereFolderIds []int64 + sql bytes.Buffer + params []interface{} +} + +func NewSearchBuilder(signedInUser *m.SignedInUser, limit int) *SearchBuilder { + searchBuilder := &SearchBuilder{ + signedInUser: signedInUser, + limit: limit, + } + + return searchBuilder +} + +func (sb *SearchBuilder) WithTags(tags []string) *SearchBuilder { + if len(tags) > 0 { + sb.tags = tags + } + + return sb +} + +func (sb *SearchBuilder) IsStarred() *SearchBuilder { + sb.isStarred = true + + return sb +} + +func (sb *SearchBuilder) WithDashboardIdsIn(ids []int64) *SearchBuilder { + if len(ids) > 0 { + sb.whereDashboardIdsIn = ids + } + + return sb +} + +func (sb *SearchBuilder) WithTitle(title string) *SearchBuilder { + sb.whereTitle = title + + return sb +} + +func (sb *SearchBuilder) WithType(queryType string) *SearchBuilder { + if len(queryType) > 0 && queryType == "dash-folder" { + sb.whereTypeFolder = true + } + + if len(queryType) > 0 && queryType == "dash-db" { + sb.whereTypeDash = true + } + + return sb +} + +func (sb *SearchBuilder) WithFolderIds(folderIds []int64) *SearchBuilder { + sb.whereFolderIds = folderIds + return sb +} + +// ToSql builds the sql and returns it as a string, together with the params. +func (sb *SearchBuilder) ToSql() (string, []interface{}) { + sb.params = make([]interface{}, 0) + + sb.buildSelect() + + if len(sb.tags) > 0 { + sb.buildTagQuery() + } else { + sb.buildMainQuery() + } + + sb.sql.WriteString(` + LEFT OUTER JOIN dashboard folder on folder.id = dashboard.folder_id + LEFT OUTER JOIN dashboard_tag on dashboard.id = dashboard_tag.dashboard_id`) + + sb.sql.WriteString(" ORDER BY dashboard.title ASC LIMIT 5000") + + return sb.sql.String(), sb.params +} + +func (sb *SearchBuilder) buildSelect() { + sb.sql.WriteString( + `SELECT + dashboard.id, + dashboard.title, + dashboard.slug, + dashboard_tag.term, + dashboard.is_folder, + dashboard.folder_id, + folder.slug as folder_slug, + folder.title as folder_title + FROM `) +} + +func (sb *SearchBuilder) buildTagQuery() { + sb.sql.WriteString( + `( + SELECT + dashboard.id FROM dashboard + LEFT OUTER JOIN dashboard_tag ON dashboard_tag.dashboard_id = dashboard.id + `) + + if sb.isStarred { + sb.sql.WriteString(" INNER JOIN star on star.dashboard_id = dashboard.id") + } + + sb.sql.WriteString(` WHERE dashboard_tag.term IN (?` + strings.Repeat(",?", len(sb.tags)-1) + `) AND `) + for _, tag := range sb.tags { + sb.params = append(sb.params, tag) + } + + sb.buildSearchWhereClause() + + // this ends the inner select (tag filtered part) + sb.sql.WriteString(` + GROUP BY dashboard.id HAVING COUNT(dashboard.id) >= ? + LIMIT ?) as ids + INNER JOIN dashboard on ids.id = dashboard.id + `) + + sb.params = append(sb.params, len(sb.tags)) + sb.params = append(sb.params, sb.limit) +} + +func (sb *SearchBuilder) buildMainQuery() { + sb.sql.WriteString(`( SELECT dashboard.id FROM dashboard `) + + if sb.isStarred { + sb.sql.WriteString(" INNER JOIN star on star.dashboard_id = dashboard.id") + } + + sb.sql.WriteString(` WHERE `) + sb.buildSearchWhereClause() + + sb.sql.WriteString(` + LIMIT ?) as ids + INNER JOIN dashboard on ids.id = dashboard.id + `) + sb.params = append(sb.params, sb.limit) +} + +func (sb *SearchBuilder) buildSearchWhereClause() { + sb.sql.WriteString(` dashboard.org_id=?`) + sb.params = append(sb.params, sb.signedInUser.OrgId) + + if sb.isStarred { + sb.sql.WriteString(` AND star.user_id=?`) + sb.params = append(sb.params, sb.signedInUser.UserId) + } + + if len(sb.whereDashboardIdsIn) > 0 { + sb.sql.WriteString(` AND dashboard.id IN (?` + strings.Repeat(",?", len(sb.whereDashboardIdsIn)-1) + `)`) + for _, dashboardId := range sb.whereDashboardIdsIn { + sb.params = append(sb.params, dashboardId) + } + } + + if sb.signedInUser.OrgRole != m.ROLE_ADMIN { + allowedDashboardsSubQuery := ` AND (dashboard.has_acl = 0 OR dashboard.id in ( + SELECT distinct d.id AS DashboardId + FROM dashboard AS d + LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id + LEFT JOIN team_member as ugm on ugm.team_id = da.team_id + LEFT JOIN org_user ou on ou.role = da.role + WHERE + d.has_acl = 1 and + (da.user_id = ? or ugm.user_id = ? or ou.id is not null) + and d.org_id = ? + ) + )` + + sb.sql.WriteString(allowedDashboardsSubQuery) + sb.params = append(sb.params, sb.signedInUser.UserId, sb.signedInUser.UserId, sb.signedInUser.OrgId) + } + + if len(sb.whereTitle) > 0 { + sb.sql.WriteString(" AND dashboard.title " + dialect.LikeStr() + " ?") + sb.params = append(sb.params, "%"+sb.whereTitle+"%") + } + + if sb.whereTypeFolder { + sb.sql.WriteString(" AND dashboard.is_folder = 1") + } + + if sb.whereTypeDash { + sb.sql.WriteString(" AND dashboard.is_folder = 0") + } + + if len(sb.whereFolderIds) > 0 { + sb.sql.WriteString(` AND dashboard.folder_id IN (?` + strings.Repeat(",?", len(sb.whereFolderIds)-1) + `) `) + for _, id := range sb.whereFolderIds { + sb.params = append(sb.params, id) + } + } +} diff --git a/pkg/services/sqlstore/search_builder_test.go b/pkg/services/sqlstore/search_builder_test.go new file mode 100644 index 00000000000..32ccbc583f5 --- /dev/null +++ b/pkg/services/sqlstore/search_builder_test.go @@ -0,0 +1,37 @@ +package sqlstore + +import ( + "testing" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + . "github.com/smartystreets/goconvey/convey" +) + +func TestSearchBuilder(t *testing.T) { + dialect = migrator.NewDialect("sqlite3") + + Convey("Testing building a search", t, func() { + signedInUser := &m.SignedInUser{ + OrgId: 1, + UserId: 1, + } + sb := NewSearchBuilder(signedInUser, 1000) + + Convey("When building a normal search", func() { + sql, params := sb.IsStarred().WithTitle("test").ToSql() + So(sql, ShouldStartWith, "SELECT") + So(sql, ShouldContainSubstring, "INNER JOIN dashboard on ids.id = dashboard.id") + So(sql, ShouldEndWith, "ORDER BY dashboard.title ASC LIMIT 5000") + So(len(params), ShouldBeGreaterThan, 0) + }) + + Convey("When building a search with tag filter", func() { + sql, params := sb.WithTags([]string{"tag1", "tag2"}).ToSql() + So(sql, ShouldStartWith, "SELECT") + So(sql, ShouldContainSubstring, "LEFT OUTER JOIN dashboard_tag") + So(sql, ShouldEndWith, "ORDER BY dashboard.title ASC LIMIT 5000") + So(len(params), ShouldBeGreaterThan, 0) + }) + }) +} diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go new file mode 100644 index 00000000000..3e9a6e6ec56 --- /dev/null +++ b/pkg/services/sqlstore/team.go @@ -0,0 +1,251 @@ +package sqlstore + +import ( + "bytes" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", CreateTeam) + bus.AddHandler("sql", UpdateTeam) + bus.AddHandler("sql", DeleteTeam) + bus.AddHandler("sql", SearchTeams) + bus.AddHandler("sql", GetTeamById) + bus.AddHandler("sql", GetTeamsByUser) + + bus.AddHandler("sql", AddTeamMember) + bus.AddHandler("sql", RemoveTeamMember) + bus.AddHandler("sql", GetTeamMembers) +} + +func CreateTeam(cmd *m.CreateTeamCommand) error { + return inTransaction(func(sess *DBSession) error { + + if isNameTaken, err := isTeamNameTaken(cmd.Name, 0, sess); err != nil { + return err + } else if isNameTaken { + return m.ErrTeamNameTaken + } + + team := m.Team{ + Name: cmd.Name, + OrgId: cmd.OrgId, + Created: time.Now(), + Updated: time.Now(), + } + + _, err := sess.Insert(&team) + + cmd.Result = team + + return err + }) +} + +func UpdateTeam(cmd *m.UpdateTeamCommand) error { + return inTransaction(func(sess *DBSession) error { + + if isNameTaken, err := isTeamNameTaken(cmd.Name, cmd.Id, sess); err != nil { + return err + } else if isNameTaken { + return m.ErrTeamNameTaken + } + + team := m.Team{ + Name: cmd.Name, + Updated: time.Now(), + } + + affectedRows, err := sess.Id(cmd.Id).Update(&team) + + if err != nil { + return err + } + + if affectedRows == 0 { + return m.ErrTeamNotFound + } + + return nil + }) +} + +func DeleteTeam(cmd *m.DeleteTeamCommand) error { + return inTransaction(func(sess *DBSession) error { + if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.Id); err != nil { + return err + } else if len(res) != 1 { + return m.ErrTeamNotFound + } + + deletes := []string{ + "DELETE FROM team_member WHERE team_id = ?", + "DELETE FROM team WHERE id = ?", + "DELETE FROM dashboard_acl WHERE team_id = ?", + } + + for _, sql := range deletes { + _, err := sess.Exec(sql, cmd.Id) + if err != nil { + return err + } + } + return nil + }) +} + +func isTeamNameTaken(name string, existingId int64, sess *DBSession) (bool, error) { + var team m.Team + exists, err := sess.Where("name=?", name).Get(&team) + + if err != nil { + return false, nil + } + + if exists && existingId != team.Id { + return true, nil + } + + return false, nil +} + +func SearchTeams(query *m.SearchTeamsQuery) error { + query.Result = m.SearchTeamQueryResult{ + Teams: make([]*m.SearchTeamDto, 0), + } + queryWithWildcards := "%" + query.Query + "%" + + var sql bytes.Buffer + params := make([]interface{}, 0) + + sql.WriteString(`select + team.id as id, + team.name as name, + (select count(*) from team_member where team_member.team_id = team.id) as member_count + from team as team + where team.org_id = ?`) + + params = append(params, query.OrgId) + + if query.Query != "" { + sql.WriteString(` and team.name ` + dialect.LikeStr() + ` ?`) + params = append(params, queryWithWildcards) + } + + if query.Name != "" { + sql.WriteString(` and team.name = ?`) + params = append(params, query.Name) + } + + sql.WriteString(` order by team.name asc`) + + if query.Limit != 0 { + sql.WriteString(` limit ? offset ?`) + offset := query.Limit * (query.Page - 1) + params = append(params, query.Limit, offset) + } + + if err := x.Sql(sql.String(), params...).Find(&query.Result.Teams); err != nil { + return err + } + + team := m.Team{} + countSess := x.Table("team") + if query.Query != "" { + countSess.Where(`name `+dialect.LikeStr()+` ?`, queryWithWildcards) + } + + if query.Name != "" { + countSess.Where("name=?", query.Name) + } + + count, err := countSess.Count(&team) + query.Result.TotalCount = count + + return err +} + +func GetTeamById(query *m.GetTeamByIdQuery) error { + var team m.Team + exists, err := x.Id(query.Id).Get(&team) + if err != nil { + return err + } + + if !exists { + return m.ErrTeamNotFound + } + + query.Result = &team + return nil +} + +func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { + query.Result = make([]*m.Team, 0) + + sess := x.Table("team") + sess.Join("INNER", "team_member", "team.id=team_member.team_id") + sess.Where("team_member.user_id=?", query.UserId) + + err := sess.Find(&query.Result) + if err != nil { + return err + } + + return nil +} + +func AddTeamMember(cmd *m.AddTeamMemberCommand) error { + return inTransaction(func(sess *DBSession) error { + if res, err := sess.Query("SELECT 1 from team_member WHERE team_id=? and user_id=?", cmd.TeamId, cmd.UserId); err != nil { + return err + } else if len(res) == 1 { + return m.ErrTeamMemberAlreadyAdded + } + + if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.TeamId); err != nil { + return err + } else if len(res) != 1 { + return m.ErrTeamNotFound + } + + entity := m.TeamMember{ + OrgId: cmd.OrgId, + TeamId: cmd.TeamId, + UserId: cmd.UserId, + Created: time.Now(), + Updated: time.Now(), + } + + _, err := sess.Insert(&entity) + return err + }) +} + +func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { + return inTransaction(func(sess *DBSession) error { + var rawSql = "DELETE FROM team_member WHERE team_id=? and user_id=?" + _, err := sess.Exec(rawSql, cmd.TeamId, cmd.UserId) + if err != nil { + return err + } + + return err + }) +} + +func GetTeamMembers(query *m.GetTeamMembersQuery) error { + query.Result = make([]*m.TeamMemberDTO, 0) + sess := x.Table("team_member") + sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user"))) + sess.Where("team_member.team_id=?", query.TeamId) + sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login") + sess.Asc("user.login", "user.email") + + err := sess.Find(&query.Result) + return err +} diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go new file mode 100644 index 00000000000..4a099db14ff --- /dev/null +++ b/pkg/services/sqlstore/team_test.go @@ -0,0 +1,114 @@ +package sqlstore + +import ( + "fmt" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" +) + +func TestTeamCommandsAndQueries(t *testing.T) { + + Convey("Testing Team commands & queries", t, func() { + InitTestDB(t) + + Convey("Given saved users and two teams", func() { + var userIds []int64 + for i := 0; i < 5; i++ { + userCmd := &m.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("loginuser", i), + } + err := CreateUser(userCmd) + So(err, ShouldBeNil) + userIds = append(userIds, userCmd.Result.Id) + } + + group1 := m.CreateTeamCommand{Name: "group1 name"} + group2 := m.CreateTeamCommand{Name: "group2 name"} + + err := CreateTeam(&group1) + So(err, ShouldBeNil) + err = CreateTeam(&group2) + So(err, ShouldBeNil) + + Convey("Should be able to create teams and add users", func() { + query := &m.SearchTeamsQuery{Name: "group1 name", Page: 1, Limit: 10} + err = SearchTeams(query) + So(err, ShouldBeNil) + So(query.Page, ShouldEqual, 1) + + team1 := query.Result.Teams[0] + So(team1.Name, ShouldEqual, "group1 name") + + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]}) + So(err, ShouldBeNil) + + q1 := &m.GetTeamMembersQuery{TeamId: team1.Id} + err = GetTeamMembers(q1) + So(err, ShouldBeNil) + So(q1.Result[0].TeamId, ShouldEqual, team1.Id) + So(q1.Result[0].Login, ShouldEqual, "loginuser0") + }) + + Convey("Should be able to search for teams", func() { + query := &m.SearchTeamsQuery{Query: "group", Page: 1} + err = SearchTeams(query) + So(err, ShouldBeNil) + So(len(query.Result.Teams), ShouldEqual, 2) + So(query.Result.TotalCount, ShouldEqual, 2) + + query2 := &m.SearchTeamsQuery{Query: ""} + err = SearchTeams(query2) + So(err, ShouldBeNil) + So(len(query2.Result.Teams), ShouldEqual, 2) + }) + + Convey("Should be able to return all teams a user is member of", func() { + groupId := group2.Result.Id + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[0]}) + + query := &m.GetTeamsByUserQuery{UserId: userIds[0]} + err = GetTeamsByUser(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Name, ShouldEqual, "group2 name") + }) + + Convey("Should be able to remove users from a group", func() { + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{TeamId: group1.Result.Id, UserId: userIds[0]}) + So(err, ShouldBeNil) + + q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id} + err = GetTeamMembers(q1) + So(err, ShouldBeNil) + So(len(q1.Result), ShouldEqual, 0) + }) + + Convey("Should be able to remove a group with users and permissions", func() { + groupId := group2.Result.Id + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[1]}) + So(err, ShouldBeNil) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[2]}) + So(err, ShouldBeNil) + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, TeamId: groupId}) + + err = DeleteTeam(&m.DeleteTeamCommand{Id: groupId}) + So(err, ShouldBeNil) + + query := &m.GetTeamByIdQuery{Id: groupId} + err = GetTeamById(query) + So(err, ShouldEqual, m.ErrTeamNotFound) + + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1} + err = GetDashboardAclInfoList(permQuery) + So(err, ShouldBeNil) + + So(len(permQuery.Result), ShouldEqual, 0) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 4c199d306f0..73ea07f031f 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -350,6 +350,7 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { u.name as name, u.help_flags1 as help_flags1, u.last_seen_at as last_seen_at, + (SELECT COUNT(*) FROM org_user where org_user.user_id = u.id) as org_count, org.name as org_name, org_user.role as org_role, org.id as org_id @@ -438,6 +439,10 @@ func DeleteUser(cmd *m.DeleteUserCommand) error { deletes := []string{ "DELETE FROM star WHERE user_id = ?", "DELETE FROM " + dialect.Quote("user") + " WHERE id = ?", + "DELETE FROM org_user WHERE user_id = ?", + "DELETE FROM dashboard_acl WHERE user_id = ?", + "DELETE FROM preferences WHERE user_id = ?", + "DELETE FROM team_member WHERE user_id = ?", } for _, sql := range deletes { diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index decb4682552..a65b7226eb6 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -6,7 +6,7 @@ import ( . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/models" ) func TestUserDataAccess(t *testing.T) { @@ -14,80 +14,134 @@ func TestUserDataAccess(t *testing.T) { Convey("Testing DB", t, func() { InitTestDB(t) - var err error - for i := 0; i < 5; i++ { - err = CreateUser(&models.CreateUserCommand{ - Email: fmt.Sprint("user", i, "@test.com"), - Name: fmt.Sprint("user", i), - Login: fmt.Sprint("loginuser", i), + Convey("Given 5 users", func() { + var err error + var cmd *m.CreateUserCommand + users := []m.User{} + for i := 0; i < 5; i++ { + cmd = &m.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("loginuser", i), + } + err = CreateUser(cmd) + So(err, ShouldBeNil) + users = append(users, cmd.Result) + } + + Convey("Can return the first page of users and a total count", func() { + query := m.SearchUsersQuery{Query: "", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 3) + So(query.Result.TotalCount, ShouldEqual, 5) }) - So(err, ShouldBeNil) - } - Convey("Can return the first page of users and a total count", func() { - query := models.SearchUsersQuery{Query: "", Page: 1, Limit: 3} - err = SearchUsers(&query) + Convey("Can return the second page of users and a total count", func() { + query := m.SearchUsersQuery{Query: "", Page: 2, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 3) - So(query.Result.TotalCount, ShouldEqual, 5) - }) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 2) + So(query.Result.TotalCount, ShouldEqual, 5) + }) - Convey("Can return the second page of users and a total count", func() { - query := models.SearchUsersQuery{Query: "", Page: 2, Limit: 3} - err = SearchUsers(&query) + Convey("Can return list of users matching query on user name", func() { + query := m.SearchUsersQuery{Query: "use", Page: 1, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 2) - So(query.Result.TotalCount, ShouldEqual, 5) - }) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 3) + So(query.Result.TotalCount, ShouldEqual, 5) - Convey("Can return list of users matching query on user name", func() { - query := models.SearchUsersQuery{Query: "use", Page: 1, Limit: 3} - err = SearchUsers(&query) + query = m.SearchUsersQuery{Query: "ser1", Page: 1, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 3) - So(query.Result.TotalCount, ShouldEqual, 5) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) - query = models.SearchUsersQuery{Query: "ser1", Page: 1, Limit: 3} - err = SearchUsers(&query) + query = m.SearchUsersQuery{Query: "USER1", Page: 1, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 1) - So(query.Result.TotalCount, ShouldEqual, 1) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) - query = models.SearchUsersQuery{Query: "USER1", Page: 1, Limit: 3} - err = SearchUsers(&query) + query = m.SearchUsersQuery{Query: "idontexist", Page: 1, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 1) - So(query.Result.TotalCount, ShouldEqual, 1) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 0) + So(query.Result.TotalCount, ShouldEqual, 0) + }) - query = models.SearchUsersQuery{Query: "idontexist", Page: 1, Limit: 3} - err = SearchUsers(&query) + Convey("Can return list of users matching query on email", func() { + query := m.SearchUsersQuery{Query: "ser1@test.com", Page: 1, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 0) - So(query.Result.TotalCount, ShouldEqual, 0) - }) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) + }) - Convey("Can return list of users matching query on email", func() { - query := models.SearchUsersQuery{Query: "ser1@test.com", Page: 1, Limit: 3} - err = SearchUsers(&query) + Convey("Can return list of users matching query on login name", func() { + query := m.SearchUsersQuery{Query: "loginuser1", Page: 1, Limit: 3} + err = SearchUsers(&query) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 1) - So(query.Result.TotalCount, ShouldEqual, 1) - }) + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) + }) - Convey("Can return list of users matching query on login name", func() { - query := models.SearchUsersQuery{Query: "loginuser1", Page: 1, Limit: 3} - err = SearchUsers(&query) + Convey("when a user is an org member and has been assigned permissions", func() { + err = AddOrgUser(&m.AddOrgUserCommand{LoginOrEmail: users[0].Login, Role: m.ROLE_VIEWER, OrgId: users[0].OrgId}) + So(err, ShouldBeNil) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 1) - So(query.Result.TotalCount, ShouldEqual, 1) + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[0].Id, Permission: m.PERMISSION_EDIT}) + So(err, ShouldBeNil) + + err = SavePreferences(&m.SavePreferencesCommand{UserId: users[0].Id, OrgId: users[0].OrgId, HomeDashboardId: 1, Theme: "dark"}) + So(err, ShouldBeNil) + + Convey("when the user is deleted", func() { + err = DeleteUser(&m.DeleteUserCommand{UserId: users[0].Id}) + So(err, ShouldBeNil) + + Convey("Should delete connected org users and permissions", func() { + query := &m.GetOrgUsersQuery{OrgId: 1} + err = GetOrgUsersForTest(query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1} + err = GetDashboardAclInfoList(permQuery) + So(err, ShouldBeNil) + + So(len(permQuery.Result), ShouldEqual, 0) + + prefsQuery := &m.GetPreferencesQuery{OrgId: users[0].OrgId, UserId: users[0].Id} + err = GetPreferences(prefsQuery) + So(err, ShouldBeNil) + + So(prefsQuery.Result.OrgId, ShouldEqual, 0) + So(prefsQuery.Result.UserId, ShouldEqual, 0) + }) + }) + }) }) }) } + +func GetOrgUsersForTest(query *m.GetOrgUsersQuery) error { + query.Result = make([]*m.OrgUserDTO, 0) + sess := x.Table("org_user") + sess.Join("LEFT ", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) + sess.Where("org_user.org_id=?", query.OrgId) + sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role") + + err := sess.Find(&query.Result) + return err +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index ef4a7c42e86..6a6df2b61fb 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -106,6 +106,7 @@ var ( ExternalUserMngLinkUrl string ExternalUserMngLinkName string ExternalUserMngInfo string + ViewersCanEdit bool // Http auth AdminUser string @@ -540,13 +541,14 @@ func NewConfigContext(args *CommandLineArgs) error { AllowUserSignUp = users.Key("allow_sign_up").MustBool(true) AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true) AutoAssignOrg = users.Key("auto_assign_org").MustBool(true) - AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Read Only Editor", "Viewer"}) + AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"}) VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) LoginHint = users.Key("login_hint").String() DefaultTheme = users.Key("default_theme").String() ExternalUserMngLinkUrl = users.Key("external_manage_link_url").String() ExternalUserMngLinkName = users.Key("external_manage_link_name").String() ExternalUserMngInfo = users.Key("external_manage_info").String() + ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false) // auth auth := Cfg.Section("auth") diff --git a/pkg/tsdb/cloudwatch/credentials.go b/pkg/tsdb/cloudwatch/credentials.go index 0c142bd4ea0..06848323fbb 100644 --- a/pkg/tsdb/cloudwatch/credentials.go +++ b/pkg/tsdb/cloudwatch/credentials.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go/aws/credentials/endpointcreds" + "github.com/aws/aws-sdk-go/aws/defaults" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatch" @@ -128,10 +129,10 @@ func remoteCredProvider(sess *session.Session) credentials.Provider { func ecsCredProvider(sess *session.Session, uri string) credentials.Provider { const host = `169.254.170.2` - c := ec2metadata.New(sess) + d := defaults.Get() return endpointcreds.NewProviderClient( - c.Client.Config, - c.Client.Handlers, + *d.Config, + d.Handlers, fmt.Sprintf("http://%s%s", host, uri), func(p *endpointcreds.Provider) { p.ExpiryWindow = 5 * time.Minute }) } diff --git a/public/app/app.ts b/public/app/app.ts index 8e345c6abed..b76dceb4943 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -9,7 +9,6 @@ import 'angular-native-dragdrop'; import 'angular-bindonce'; import 'react'; import 'react-dom'; -import 'ngreact'; import 'vendor/bootstrap/bootstrap'; import 'vendor/angular-ui/ui-bootstrap-tpls'; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 7acdc79d55a..83a70fa4c8a 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -1,8 +1,12 @@ import { react2AngularDirective } from 'app/core/utils/react2angular'; import { PasswordStrength } from './components/PasswordStrength'; +import PageHeader from './components/PageHeader/PageHeader'; +import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; +import LoginBackground from './components/Login/LoginBackground'; export function registerAngularDirectives() { - react2AngularDirective('passwordStrength', PasswordStrength, ['password']); - + react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); + react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); + react2AngularDirective('loginBackground', LoginBackground, []); } diff --git a/public/app/core/app_events.ts b/public/app/core/app_events.ts index 4507246d2be..b9a77a5f7b7 100644 --- a/public/app/core/app_events.ts +++ b/public/app/core/app_events.ts @@ -1,5 +1,3 @@ -/// - import {Emitter} from './utils/emitter'; var appEvents = new Emitter(); diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx new file mode 100644 index 00000000000..d62ae892a0a --- /dev/null +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import EmptyListCTA from './EmptyListCTA'; + +const model = { + title: 'Title', + buttonIcon: 'ga css class', + buttonLink: 'http://url/to/destination', + buttonTitle: 'Click me', + proTip: 'This is a tip', + proTipLink: 'http://url/to/tip/destination', + proTipLinkTitle: 'Learn more', + proTipTarget: '_blank' +}; + +describe('CollorPalette', () => { + + it('renders correctly', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx new file mode 100644 index 00000000000..1583303dfa1 --- /dev/null +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; + +export interface IProps { + model: any; +} + +class EmptyListCTA extends Component { + render() { + const { + title, + buttonIcon, + buttonLink, + buttonTitle, + proTip, + proTipLink, + proTipLinkTitle, + proTipTarget + } = this.props.model; + return ( +
+
{title}
+ {buttonTitle} +
+ ProTip: {proTip} + {proTipLinkTitle} +
+
+ ); + } +} + +export default EmptyListCTA; diff --git a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap new file mode 100644 index 00000000000..0da3d94aaa8 --- /dev/null +++ b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollorPalette renders correctly 1`] = ` +
+
+ Title +
+ + + Click me + +
+ + ProTip: + This is a tip + + Learn more + +
+
+`; diff --git a/public/app/core/components/Login/LoginBackground.tsx b/public/app/core/components/Login/LoginBackground.tsx new file mode 100644 index 00000000000..83e228ab6e0 --- /dev/null +++ b/public/app/core/components/Login/LoginBackground.tsx @@ -0,0 +1,1240 @@ +import React, { Component } from 'react'; + +const xCount = 50; +const yCount = 50; + +function Cell({ x, y, flipIndex }) { + const index = (y * xCount) + x; + const bgColor1 = getColor(x, y); + return ( +
+ ); +} + +function getRandomInt(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive +} + +export default class LoginBackground extends Component { + cancelInterval: any; + + constructor(props) { + super(props); + + this.state = { + flipIndex: null, + }; + + this.flipElements = this.flipElements.bind(this); + } + + flipElements() { + const elementIndexToFlip = getRandomInt(0, (xCount * yCount) - 1); + this.setState(prevState => { + return { + ...prevState, + flipIndex: elementIndexToFlip, + }; + }); + } + + componentWillMount() { + this.cancelInterval = setInterval(this.flipElements, 3000); + } + + componentWillUnmount() { + clearInterval(this.cancelInterval); + } + + render() { + console.log('re-render!', this.state.flipIndex); + + return ( +
+ {Array.from(Array(yCount)).map((el, y) => { + return ( +
+ {Array.from(Array(xCount)).map((el2, x) => { + return ( + + ); + })} +
+ ); + })} +
+ ); + } +} + +function getColor(x, y) { + const colors = [ + '#14161A', + '#111920', + '#121E27', + '#13212B', + '#122029', + '#101C24', + '#0F1B23', + '#0F1B22', + '#111C24', + '#101A22', + '#101A21', + '#111D25', + '#101E27', + '#101D26', + '#101B23', + '#11191E', + '#131519', + '#131518', + '#101B21', + '#121F29', + '#10232D', + '#11212B', + '#0E1C25', + '#0E1C24', + '#111F29', + '#11222B', + '#101E28', + '#102028', + '#111F2A', + '#11202A', + '#11191F', + '#121417', + '#12191D', + '#101D25', + '#11212C', + '#10242F', + '#0F212B', + '#0F1E27', + '#0F1D26', + '#0F1F29', + '#0F2029', + '#11232E', + '#10212B', + '#10222C', + '#0F202A', + '#112530', + '#10252F', + '#0F242E', + '#10222D', + '#10202A', + '#0F1C24', + '#0F1E28', + '#0F212A', + '#0F222B', + '#14171A', + '#0F1A20', + '#0F1C25', + '#10232E', + '#0E202A', + '#0E1E27', + '#0E1D26', + '#0F202B', + '#11232F', + '#102632', + '#102530', + '#122430', + '#0F1B21', + '#0F212C', + '#0E1F29', + '#112531', + '#0F2734', + '#0F2835', + '#0D1B23', + '#0F1A21', + '#0F1A23', + '#0F1D27', + '#0F222D', + '#102430', + '#102531', + '#10222E', + '#0F232D', + '#0E2633', + '#0E2734', + '#0F2834', + '#0E2835', + '#0F2633', + '#0F2532', + '#0E1A22', + '#0D1C24', + '#0F2735', + '#0F2937', + '#102A38', + '#112938', + '#102A39', + '#0F2A38', + '#102836', + '#0E1B23', + '#0F2938', + '#102A3A', + '#102D3D', + '#0F3040', + '#102D3E', + '#0F2E3E', + '#112C3B', + '#102B3B', + '#102B3A', + '#102D3C', + '#0F2A39', + '#0F2634', + '#0E2029', + '#0E1A21', + '#0F2B39', + '#0F2D3D', + '#0F2F40', + '#0E3142', + '#113445', + '#122431', + '#102E3E', + '#0F3345', + '#0E2F40', + '#0F3143', + '#102C3C', + '#0F2B3A', + '#0F1F28', + '#0F3344', + '#113548', + '#113C51', + '#144258', + '#103A4E', + '#103A4F', + '#103547', + '#10364A', + '#103649', + '#0F3448', + '#102C3A', + '#0F2836', + '#103447', + '#0F384C', + '#123F55', + '#15445A', + '#133F55', + '#103B50', + '#113E54', + '#103446', + '#0F3A4F', + '#0F3548', + '#0D3142', + '#102C3B', + '#0E2937', + '#103D52', + '#0E3544', + '#184C65', + '#154760', + '#14435B', + '#15465F', + '#124159', + '#0F3D53', + '#103C51', + '#0F3447', + '#0E3243', + '#113143', + '#113D53', + '#184B64', + '#184D67', + '#184C66', + '#174A63', + '#15455C', + '#13425A', + '#14445A', + '#10384C', + '#0E3446', + '#10181E', + '#103243', + '#0F384D', + '#14455C', + '#164761', + '#164C66', + '#1D627D', + '#12425A', + '#164A63', + '#14465D', + '#13435A', + '#0A2B38', + '#0F3446', + '#0D2F40', + '#0D2F3F', + '#0F2531', + '#102937', + '#10384B', + '#0F3649', + '#184E68', + '#1A5472', + '#184D68', + '#154A63', + '#19506B', + '#19536F', + '#1A4F69', + '#144760', + '#114058', + '#0E3A4F', + '#0E3547', + '#0C3042', + '#0E1B24', + '#11222C', + '#154C65', + '#1A5776', + '#1B5675', + '#113847', + '#1A5371', + '#194E68', + '#0E2D3D', + '#112D3B', + '#113D52', + '#18516D', + '#1A5979', + '#1B5878', + '#19526E', + '#1A526E', + '#13435B', + '#0F3E55', + '#0B374C', + '#0E3448', + '#0D2E3F', + '#0F2B3B', + '#112E3E', + '#113B50', + '#15465D', + '#1A526F', + '#1E5E81', + '#1D5B7B', + '#1A5777', + '#154456', + '#113949', + '#0D394E', + '#0F3549', + '#0F2C3B', + '#0E2733', + '#112E3D', + '#123D52', + '#10394C', + '#1B5674', + '#1A5370', + '#144861', + '#104058', + '#104159', + '#0E384C', + '#0D2D3D', + '#0E2533', + '#112C3A', + '#1B5979', + '#1B5C7D', + '#1A5675', + '#104057', + '#0F3C51', + '#11425A', + '#0E394D', + '#0C3243', + '#0E2735', + '#112F3E', + '#134158', + '#1D5E7F', + '#1D6083', + '#1C5877', + '#1A5573', + '#184D66', + '#164962', + '#0F3D54', + '#0E3D53', + '#0E3447', + '#0F2A3A', + '#0F2936', + '#101F28', + '#103040', + '#124056', + '#164E69', + '#144B64', + '#164D66', + '#0F3E54', + '#0E3B51', + '#0D3346', + '#0E1F27', + '#124158', + '#164961', + '#0E3C52', + '#19506C', + '#0F2C3C', + '#0E3244', + '#0E2A39', + '#0E2938', + '#113040', + '#134057', + '#1A5471', + '#154B63', + '#1C597A', + '#164760', + '#10374B', + '#0E374C', + '#0E384D', + '#11242F', + '#10394D', + '#18526E', + '#154B65', + '#103F55', + '#0D3345', + '#102532', + '#102029', + '#113142', + '#1B5973', + '#1A516B', + '#1C5979', + '#1C5A7A', + '#184A65', + '#164C65', + '#0D3041', + '#123142', + '#123E54', + '#1B5877', + '#1A5574', + '#1C5878', + '#13435C', + '#0F374B', + '#0C3143', + '#112F40', + '#123C51', + '#174E68', + '#1D5C7D', + '#14465F', + '#0F3F56', + '#0B3041', + '#123243', + '#15435B', + '#19516D', + '#1D5D7E', + '#1C5C7D', + '#184F69', + '#11374B', + '#103E54', + '#0E3143', + '#0F2D3C', + '#11242E', + '#133445', + '#1A5674', + '#1D6184', + '#1F658B', + '#0D3A50', + '#0C374B', + '#154862', + '#164B64', + '#154961', + '#0D384D', + '#102631', + '#113242', + '#134259', + '#185270', + '#1D6386', + '#1E678C', + '#1C5978', + '#0D3549', + '#0F2632', + '#184961', + '#1D5E80', + '#1E6488', + '#1F678D', + '#1E5B7C', + '#164862', + '#19526D', + '#113C52', + '#15455E', + '#0F2F3F', + '#144259', + '#194D67', + '#1D6991', + '#195777', + '#19516C', + '#103F56', + '#144660', + '#0D2E3E', + '#10212A', + '#113141', + '#16455C', + '#1D5B7C', + '#1F6589', + '#1E668C', + '#1E5F81', + '#0F3B50', + '#0D3244', + '#164A64', + '#184E69', + '#0E364A', + '#0E2E3E', + '#10222B', + '#19475E', + '#1B5A7B', + '#1E5D7F', + '#1E678D', + '#1E6184', + '#19506A', + '#1B5370', + '#1B5573', + '#0E3041', + '#122E3E', + '#16455B', + '#195370', + '#1D6489', + '#1D6B93', + '#164A65', + '#154A64', + '#1A5572', + '#1D6082', + '#1F6286', + '#1D6C94', + '#1E709A', + '#174A65', + '#1B526F', + '#1E6589', + '#1D6384', + '#0D3143', + '#0E2F3F', + '#174760', + '#1F6487', + '#1D668C', + '#0D2F41', + '#103B4F', + '#1C5C7E', + '#1F688F', + '#1C5B7C', + '#164D68', + '#1D6285', + '#0D364A', + '#1D5A7A', + '#1E6990', + '#1D6488', + '#18516B', + '#1A506B', + '#0E3B50', + '#0E3548', + '#124259', + '#13455C', + '#14485F', + '#1E5C7D', + '#122D3C', + '#1E6E98', + '#1E6A91', + '#1E6286', + '#1E6C95', + '#1D6990', + '#101F29', + '#174A62', + '#10394E', + '#1D6D96', + '#1E688E', + '#1D6E97', + '#1E6C94', + '#0E394E', + '#112B39', + '#195270', + '#1E668B', + '#1E6386', + '#1D6385', + '#0C3142', + '#1E6083', + '#1E729C', + '#1F709A', + '#1E6F98', + '#1D5F81', + '#1F688D', + '#1C6488', + '#1D6588', + '#1C6A93', + '#1E658B', + '#1F6C95', + '#0D3C52', + '#1C6385', + '#1E5F82', + '#0E3D54', + '#0F3244', + '#18485F', + '#1E6991', + '#1C5B7B', + '#1F6082', + '#0F3346', + '#18536F', + '#114056', + '#1D6B92', + '#1B5776', + '#0F3C52', + '#1E6890', + '#1F688E', + '#0C394E', + '#0F1D25', + '#1F6386', + '#1E688D', + '#1F6488', + '#20668C', + '#1D5978', + '#0F3D52', + '#0F1E26', + '#13465F', + '#0D374C', + '#1B5C7C', + '#0E1A23', + '#0F374A', + '#1B5574', + '#0F394C', + '#0E2A38', + '#102A37', + '#18506B', + '#1E5A7A', + '#0F3245', + '#0E2E3F', + '#1E678E', + '#1C5D7E', + '#1A5A7A', + '#0E2837', + '#102733', + '#0F3B51', + '#15475E', + '#1E6B93', + '#1E648A', + '#194961', + '#0F3A4E', + '#0E1D25', + '#194F69', + '#103345', + '#0F394D', + '#102B39', + '#103E55', + '#1B5572', + '#164861', + '#174861', + '#113B4F', + '#102936', + '#0F3041', + '#174961', + '#113E53', + '#134056', + '#124057', + '#194B63', + '#0E364B', + '#15445B', + '#16475E', + '#102F3F', + '#16485F', + '#0F2E3D', + '#101920', + '#12222C', + '#122C3B', + '#144157', + '#123B50', + '#16465D', + '#184960', + '#112B3A', + '#12232F', + '#132430', + '#113344', + '#11394C', + '#113649', + '#11364A', + '#133F56', + '#121D25', + '#112733', + '#112A38', + '#0F1F2A', + '#113447', + '#113A4E', + '#0F222C', + '#13222B', + '#112836', + '#102F3E', + '#113243', + '#123445', + '#12374B', + '#121E26', + '#122531', + '#11303F', + '#0D1D25', + '#102835', + '#112834', + '#101C23', + '#111C23', + '#12212B', + '#11222D', + '#0E1B22', + '#0E1D27', + '#121C22', + '#12202A', + '#101A20', + '#13191E', + '#111E28', + '#11212D', + '#0F1B24', + '#0F1C23', + '#13181D', + '#15171A', + '#121D23', + '#121F27', + '#111E27', + '#101B22', + '#121F28', + '#111E26', + '#101D24', + '#111C22', + '#12161E', + '#101925', + '#121E2D', + '#112033', + '#111E2F', + '#0F1B29', + '#0F1A28', + '#101B2A', + '#0E1A27', + '#101C2B', + '#111D2D', + '#111D2B', + '#0F1B28', + '#101923', + '#13161D', + '#13161C', + '#0F1A26', + '#101E2F', + '#112235', + '#102031', + '#0F1B2A', + '#112031', + '#102032', + '#101D2E', + '#121F2F', + '#112133', + '#101E30', + '#101F30', + '#102336', + '#101B2C', + '#0F1C2B', + '#111E2E', + '#0F2134', + '#102236', + '#0F2133', + '#101F31', + '#0F2438', + '#102337', + '#102235', + '#102133', + '#11171E', + '#101F2F', + '#102030', + '#102234', + '#102132', + '#12181F', + '#0F1A25', + '#0F2135', + '#0F1F30', + '#0F1C2D', + '#101D2C', + '#0F2033', + '#0E2338', + '#0F2237', + '#0F2236', + '#0B243B', + '#0D2338', + '#0E1A26', + '#0F1D2E', + '#0F2032', + '#0D2339', + '#0B253F', + '#0A253F', + '#0A253E', + '#0C2439', + '#0E1925', + '#0E2135', + '#0F2235', + '#0A243A', + '#08253E', + '#09253E', + '#0A263F', + '#0A243C', + '#0B233B', + '#0E1A28', + '#0D1A26', + '#09253F', + '#0A2743', + '#0B2844', + '#0B2641', + '#0A2744', + '#0A2844', + '#0B2743', + '#092745', + '#0F2337', + '#101D2D', + '#092743', + '#092846', + '#0E2B4C', + '#102E4F', + '#0E2C4D', + '#0B2A49', + '#082947', + '#0D2B4B', + '#0C2A4A', + '#092946', + '#082845', + '#0C2B4B', + '#0F2D4E', + '#103051', + '#133257', + '#0E2D4E', + '#143156', + '#112F51', + '#0B243A', + '#082744', + '#092844', + '#123054', + '#143359', + '#173A64', + '#183F6E', + '#173F6D', + '#153961', + '#163962', + '#133358', + '#15345B', + '#14345A', + '#102F50', + '#0A2948', + '#082844', + '#092641', + '#16375F', + '#193C69', + '#174170', + '#173E6B', + '#163A63', + '#173D69', + '#183D6A', + '#15365E', + '#112E50', + '#0A2A49', + '#082743', + '#0E1927', + '#173C68', + '#13487E', + '#164476', + '#174375', + '#193F6F', + '#173B66', + '#163B65', + '#082A48', + '#0A2641', + '#09243C', + '#174171', + '#14477C', + '#124980', + '#14487F', + '#174374', + '#15467B', + '#184172', + '#17406F', + '#184070', + '#163C67', + '#16355D', + '#123256', + '#0E1B29', + '#0F1923', + '#113052', + '#184274', + '#164579', + '#13477C', + '#193E6D', + '#0A243E', + '#0B233A', + '#0D1A29', + '#0B2742', + '#17365E', + '#163860', + '#124A84', + '#095191', + '#114A83', + '#0D4D8A', + '#0C4D8C', + '#104B85', + '#15477E', + '#174477', + '#183862', + '#0A233A', + '#092947', + '#09243D', + '#173963', + '#194173', + '#085396', + '#085394', + '#114B87', + '#144983', + '#094F8E', + '#075090', + '#0F4C89', + '#215287', + '#0E1A29', + '#184376', + '#0C4D8B', + '#07549A', + '#0A4E8D', + '#0F4C88', + '#0A4E8C', + '#174273', + '#193C6A', + '#0B2948', + '#0B2C4B', + '#0C4E8D', + '#1259A4', + '#0C579E', + '#0D4D8B', + '#095397', + '#085397', + '#085295', + '#144880', + '#173861', + '#15335A', + '#0F2C4D', + '#0C2949', + '#0B4E8D', + '#08559C', + '#07508F', + '#154578', + '#17365F', + '#122F53', + '#111D2C', + '#092A48', + '#08559D', + '#08559E', + '#0C56A1', + '#164271', + '#163E6A', + '#194071', + '#082642', + '#0F1E30', + '#0D2D4D', + '#114C87', + '#0E59A3', + '#135BA6', + '#085498', + '#085497', + '#095192', + '#0E4D8B', + '#0C4E8A', + '#134982', + '#17457B', + '#121F2E', + '#183E6C', + '#153E69', + '#07508E', + '#173F6C', + '#193D6B', + '#112D4F', + '#0A243B', + '#072946', + '#111E2D', + '#0B2740', + '#10497F', + '#17406E', + '#084F8D', + '#104A80', + '#0E2E4F', + '#143358', + '#16365D', + '#0A2742', + '#13477B', + '#154474', + '#104C86', + '#095291', + '#0B4F8E', + '#114A80', + '#095090', + '#075296', + '#163760', + '#2D6DB5', + '#0C2843', + '#0C233A', + '#153A62', + '#14467A', + '#075498', + '#085293', + '#09263F', + '#122030', + '#09559D', + '#0F4B83', + '#08549A', + '#14375D', + '#085499', + '#075499', + '#0A243D', + '#143E68', + '#10497E', + '#074F8E', + '#085496', + '#0C58A3', + '#065499', + '#085190', + '#0A2B4A', + '#104C88', + '#0D4F8E', + '#0F58A2', + '#0B569B', + '#0D58A1', + '#134A81', + '#09559C', + '#0A5293', + '#114B86', + '#0D2C4C', + '#103255', + '#16457A', + '#074F8C', + '#07559C', + '#185DA9', + '#1D61AD', + '#175CA8', + '#16406D', + '#153C65', + '#0E243A', + '#144679', + '#085192', + '#1A5EAC', + '#1D61AE', + '#11497F', + '#12487E', + '#0C243C', + '#123155', + '#0F59A3', + '#1B5FAB', + '#1E61AD', + '#145CA4', + '#0E599F', + '#11497E', + '#094F8D', + '#15345A', + '#134A85', + '#165CA8', + '#2263AF', + '#124466', + '#0A518F', + '#08569D', + '#16416F', + '#0B2B4A', + '#124A83', + '#0C57A2', + '#1E60AD', + '#1E62AE', + '#165DA8', + '#1059A4', + '#15406C', + '#0A4F8E', + '#12365A', + '#0A5191', + '#16355C', + '#1C5EAB', + '#155CA7', + '#085292', + '#174478', + '#153258', + '#111F2F', + '#174272', + '#1159A5', + '#1C5EAC', + '#2F74BB', + '#0C58A2', + '#0D59A3', + '#14477D', + '#132F53', + '#155BA6', + '#195FAA', + '#2366B1', + '#2967B2', + '#14477E', + '#1B5EAB', + '#175DA8', + '#0F4C86', + '#065090', + '#1C5FAC', + '#185CA8', + '#0D58A3', + '#0C4E8C', + '#134981', + '#14416D', + '#0F5AA5', + '#1F63AF', + '#114B88', + '#09508E', + '#0A569D', + '#195DAA', + '#0F1D2F', + '#1059A2', + '#0E599E', + '#2063AF', + '#1F63AE', + '#1A5EAA', + '#0C57A0', + '#195EAA', + '#1A5EA9', + '#0E4E8A', + '#12487D', + '#185DAA', + '#175EAA', + '#0A508E', + '#1559A6', + '#0E58A3', + '#095399', + '#0B4E8B', + '#0B569F', + '#0C57A1', + '#2967B1', + '#2365B0', + '#2163AE', + '#1A5DAA', + '#195EAB', + '#1E5FAC', + '#2564AF', + '#2767B1', + '#2766B1', + '#0D5A9F', + '#2062AE', + '#1F61AD', + '#195FAB', + '#0D4E8D', + '#173760', + '#111D2E', + '#09518F', + '#1A5FAC', + '#135BA7', + '#085291', + '#183761', + '#0B2845', + '#113457', + '#075393', + '#185EA9', + '#2B69B3', + '#2A67B2', + '#2867B1', + '#155DA8', + '#135CA6', + '#135AA5', + '#114980', + '#2566B1', + '#2064AF', + '#2364AF', + '#13365B', + '#154475', + '#08549B', + '#164373', + '#085392', + '#144576', + '#12497E', + '#0E5392', + '#135BA3', + '#0C5395', + '#0C5291', + '#0E579C', + '#0E5290', + '#134C83', + '#2163AC', + '#195CA6', + '#0D4E8C', + '#082945', + '#133256', + '#0E2F50', + '#105AA6', + '#134677', + '#144475', + '#145BA7', + '#154270', + '#1D60AD', + '#09569B', + '#09243E', + '#134A86', + '#0E59A4', + '#0A4E8B', + '#0E4B83', + '#1D5EAC', + '#101C2A', + '#134A84', + '#0E518F', + '#145CA7', + '#0E5699', + '#145BA5', + '#095292', + '#15416E', + '#153D67', + '#153F6B', + '#125AA5', + '#16406E', + '#0E1B27', + '#0D4F8C', + '#0F58A3', + '#114A82', + '#09569C', + '#0C2339', + '#0E1B28', + '#0D59A4', + '#07559D', + '#08569E', + '#095190', + '#0B253E', + '#0C2B49', + '#2264AF', + '#09549A', + '#09569F', + '#163D68', + '#0C263F', + '#143960', + '#183A65', + '#075496', + '#0C579F', + '#085191', + '#102438', + '#075295', + '#082946', + '#102437', + '#0C2642', + '#101C29', + '#0C253E', + '#15355C', + '#0B2E4D', + '#0F3253', + '#154577', + '#16335B', + '#0F1925', + '#0C2742', + '#0B2946', + '#0E2C4B', + '#0E2B48', + '#0E2237', + '#102237', + '#0B253D', + '#0A2946', + '#0C2841', + '#0D2A47', + '#0C2C4A', + '#08253F', + '#08243D', + '#111C2B', + '#0C2844', + '#0C2945', + '#0D243A', + '#122134', + '#0B2642', + '#113154', + '#113255', + '#0A2642', + '#0A2945', + '#0B263F', + '#0D2E4E', + '#0F1E2E', + '#0A2845', + '#0D2439', + '#0F1A29', + '#101C2E', + '#111923', + '#13181F', + '#111D2F', + '#111F30', + '#121E30', + '#121E2E', + '#101B27', + '#101A27', + '#13171F', + ]; + + // let randX = getRandomInt(0, x); + // let randY = getRandomInt(0, y); + // let randIndex = randY * xCount + randX; + + return colors[(y*xCount + x) % colors.length]; +} diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx new file mode 100644 index 00000000000..fb09ed29085 --- /dev/null +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { NavModel, NavModelItem } from '../../nav_model_srv'; +import classNames from 'classnames'; +import appEvents from 'app/core/app_events'; + +export interface IProps { + model: NavModel; +} + +function TabItem(tab: NavModelItem) { + if (tab.hideFromTabs) { + return (null); + } + + let tabClasses = classNames({ + 'gf-tabs-link': true, + active: tab.active, + }); + + return ( +
  • + + + {tab.text} + +
  • + ); +} + +function SelectOption(navItem: NavModelItem) { + if (navItem.hideFromTabs) { // TODO: Rename hideFromTabs => hideFromNav + return (null); + } + + return ( + + ); +} + +function Navigation({main}: {main: NavModelItem}) { + return (); +} + +function SelectNav({main, customCss}: {main: NavModelItem, customCss: string}) { + const defaultSelectedItem = main.children.find(navItem => { + return navItem.active === true; + }); + + const gotoUrl = evt => { + var element = evt.target; + var url = element.options[element.selectedIndex].value; + appEvents.emit('location-change', {href: url}); + }; + + return (); +} + +function Tabs({main, customCss}: {main: NavModelItem, customCss: string}) { + return
      {main.children.map(TabItem)}
    ; +} + +export default class PageHeader extends React.Component { + constructor(props) { + super(props); + } + + renderBreadcrumb(breadcrumbs) { + const breadcrumbsResult = []; + for (let i = 0; i < breadcrumbs.length; i++) { + const bc = breadcrumbs[i]; + if (bc.url) { + breadcrumbsResult.push({bc.title}); + } else { + breadcrumbsResult.push( / {bc.title}); + } + } + return breadcrumbsResult; + } + + renderHeaderTitle(main) { + return ( +
    + + {main.icon && } + {main.img && } + + +
    + {main.text &&

    {main.text}

    } + {main.breadcrumbs && main.breadcrumbs.length > 0 && ( +

    + {this.renderBreadcrumb(main.breadcrumbs)} +

    ) + } + {main.subTitle &&
    {main.subTitle}
    } + {main.subType && ( +
    + + {main.subType.text} +
    + )} +
    +
    + ); + } + + render() { + const { model } = this.props; + + if (!model) { + return null; + } + + return ( +
    +
    +
    + {this.renderHeaderTitle(model.main)} + {model.main.children && } +
    +
    +
    + ); + } +} diff --git a/public/app/core/components/PasswordStrength.tsx b/public/app/core/components/PasswordStrength.tsx index 1fede3e5cd5..8f92b18445c 100644 --- a/public/app/core/components/PasswordStrength.tsx +++ b/public/app/core/components/PasswordStrength.tsx @@ -11,15 +11,20 @@ export class PasswordStrength extends React.Component { } render() { + const { password } = this.props; let strengthText = "strength: strong like a bull."; let strengthClass = "password-strength-good"; - if (this.props.password.length <= 8) { + if (!password) { + return null; + } + + if (password.length <= 8) { strengthText = "strength: you can do better."; strengthClass = "password-strength-ok"; } - if (this.props.password.length < 4) { + if (password.length < 4) { strengthText = "strength: weak sauce."; strengthClass = "password-strength-bad"; } diff --git a/public/app/core/components/ScrollBar/ScrollBar.tsx b/public/app/core/components/ScrollBar/ScrollBar.tsx new file mode 100644 index 00000000000..49a200b0f3b --- /dev/null +++ b/public/app/core/components/ScrollBar/ScrollBar.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import PerfectScrollbar from 'perfect-scrollbar'; + +export interface Props { + children: any; + className: string; +} + +export default class ScrollBar extends React.Component { + + private container: any; + private ps: PerfectScrollbar; + + constructor(props) { + super(props); + } + + componentDidMount() { + this.ps = new PerfectScrollbar(this.container); + } + + componentDidUpdate() { + this.ps.update(); + } + + componentWillUnmount() { + this.ps.destroy(); + } + + // methods can be invoked by outside + setScrollTop(top) { + if (this.container) { + this.container.scrollTop = top; + this.ps.update(); + + return true; + } + return false; + } + + setScrollLeft(left) { + if (this.container) { + this.container.scrollLeft = left; + this.ps.update(); + + return true; + } + return false; + } + + handleRef = ref => { + this.container = ref; + }; + + render() { + return ( +
    + {this.props.children} +
    + ); + } +} diff --git a/public/app/core/components/dashboard_selector.ts b/public/app/core/components/dashboard_selector.ts index 7ec9f681520..ac8b30e7733 100644 --- a/public/app/core/components/dashboard_selector.ts +++ b/public/app/core/components/dashboard_selector.ts @@ -1,5 +1,3 @@ -/// - import coreModule from 'app/core/core_module'; var template = ` diff --git a/public/app/core/components/gf_page.ts b/public/app/core/components/gf_page.ts new file mode 100644 index 00000000000..d5ac3e55c88 --- /dev/null +++ b/public/app/core/components/gf_page.ts @@ -0,0 +1,42 @@ +/// + +import coreModule from 'app/core/core_module'; + +const template = ` +
    + +
    + + +
    +
    +
    +
    +`; + +export function gfPageDirective() { + return { + restrict: 'E', + template: template, + scope: { + "model": "=", + }, + transclude: { + 'header': '?gfPageHeader', + 'body': 'gfPageBody', + }, + link: function(scope, elem, attrs) { + console.log(scope); + } + }; +} + +coreModule.directive('gfPage', gfPageDirective); diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 8852da4a436..5db9b71f9e9 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -1,5 +1,3 @@ -/// - import config from 'app/core/config'; import _ from 'lodash'; import $ from 'jquery'; @@ -12,7 +10,7 @@ import Drop from 'tether-drop'; export class GrafanaCtrl { /** @ngInject */ - constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv) { + constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, globalEventSrv) { $scope.init = function() { $scope.contextSrv = contextSrv; @@ -23,6 +21,7 @@ export class GrafanaCtrl { profiler.init(config, $rootScope); alertSrv.init(); utilSrv.init(); + globalEventSrv.init(); $scope.dashAlerts = alertSrv; }; @@ -64,37 +63,30 @@ export class GrafanaCtrl { } /** @ngInject */ -export function grafanaAppDirective(playlistSrv, contextSrv) { +export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScope) { return { restrict: 'E', controller: GrafanaCtrl, link: (scope, elem) => { - var ignoreSideMenuHide; + var sidemenuOpen; var body = $('body'); // see https://github.com/zenorocha/clipboard.js/issues/155 $.fn.modal.Constructor.prototype.enforceFocus = function() {}; - // handle sidemenu open state - scope.$watch('contextSrv.sidemenu', newVal => { - if (newVal !== undefined) { - body.toggleClass('sidemenu-open', scope.contextSrv.sidemenu); - if (!newVal) { - contextSrv.setPinnedState(false); - } - } - if (contextSrv.sidemenu) { - ignoreSideMenuHide = true; - setTimeout(() => { - ignoreSideMenuHide = false; - }, 300); - } + sidemenuOpen = scope.contextSrv.sidemenu; + body.toggleClass('sidemenu-open', sidemenuOpen); + + appEvents.on('toggle-sidemenu', () => { + body.toggleClass('sidemenu-open'); }); - scope.$watch('contextSrv.pinned', newVal => { - if (newVal !== undefined) { - body.toggleClass('sidemenu-pinned', newVal); - } + appEvents.on('toggle-sidemenu-mobile', () => { + body.toggleClass('sidemenu-open--xs'); + }); + + appEvents.on('toggle-sidemenu-hidden', () => { + body.toggleClass('sidemenu-hidden'); }); // tooltip removal fix @@ -112,6 +104,9 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { } } + // clear body class sidemenu states + body.removeClass('sidemenu-open--xs'); + $("#tooltip, .tooltip").remove(); // check for kiosk url param @@ -134,6 +129,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { var lastActivity = new Date().getTime(); var activeUser = true; var inActiveTimeLimit = 60 * 1000; + var sidemenuHidden = false; function checkForInActiveUser() { if (!activeUser) { @@ -147,6 +143,14 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { if ((new Date().getTime() - lastActivity) > inActiveTimeLimit) { activeUser = false; body.addClass('user-activity-low'); + // hide sidemenu + if (sidemenuOpen) { + sidemenuHidden = true; + body.removeClass('sidemenu-open'); + $timeout(function() { + $rootScope.$broadcast("render"); + }, 100); + } } } @@ -155,6 +159,15 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { if (!activeUser) { activeUser = true; body.removeClass('user-activity-low'); + + // restore sidemenu + if (sidemenuHidden) { + sidemenuHidden = false; + body.addClass('sidemenu-open'); + $timeout(function() { + $rootScope.$broadcast("render"); + }, 100); + } } } @@ -190,7 +203,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { }, 100); } - if (target.parents('.dash-playlist-actions').length === 0) { + if (target.parents('.navbar-buttons--playlist').length === 0) { playlistSrv.stop(); } @@ -203,23 +216,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { } } - // hide menus - var openMenus = body.find('.navbar-page-btn--open'); - if (openMenus.length > 0) { - if (target.parents('.navbar-page-btn--open').length === 0) { - openMenus.removeClass('navbar-page-btn--open'); - } - } - - // hide sidemenu - if (!ignoreSideMenuHide && !contextSrv.pinned && body.find('.sidemenu').length > 0) { - if (target.parents('.sidemenu').length === 0) { - scope.$apply(function() { - scope.contextSrv.toggleSideMenu(); - }); - } - } - // hide popovers var popover = elem.find('.popover'); if (popover.length > 0 && target.parents('.graph-legend').length === 0) { diff --git a/public/app/core/components/help/help.html b/public/app/core/components/help/help.html index c07d57a0ffc..45faa560e40 100644 --- a/public/app/core/components/help/help.html +++ b/public/app/core/components/help/help.html @@ -4,15 +4,6 @@ Shortcuts - - - - - - - - - diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.html b/public/app/core/components/manage_dashboards/manage_dashboards.html new file mode 100644 index 00000000000..f8c96a78b2e --- /dev/null +++ b/public/app/core/components/manage_dashboards/manage_dashboards.html @@ -0,0 +1,116 @@ +
    + + +
    +
    +
    + + +
    + + +
    +
    + +
    + + No dashboards matching your query were found. + +
    + +
    +
    + +
    + +
    + + +
    +
    +
    +
    + +
    +
    +
    + +
    + +
    diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts new file mode 100644 index 00000000000..92f01d99716 --- /dev/null +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -0,0 +1,281 @@ +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; +import { SearchSrv } from 'app/core/services/search_srv'; + +export class ManageDashboardsCtrl { + public sections: any[]; + tagFilterOptions: any[]; + selectedTagFilter: any; + query: any; + navModel: any; + canDelete = false; + canMove = false; + hasFilters = false; + selectAllChecked = false; + starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }]; + selectedStarredFilter: any; + folderId?: number; + + /** @ngInject */ + constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv) { + this.query = { query: '', mode: 'tree', tag: [], starred: false, skipRecent: true, skipStarred: true }; + + if (this.folderId) { + this.query.folderIds = [this.folderId]; + } + + this.selectedStarredFilter = this.starredFilterOptions[0]; + + this.getDashboards().then(() => { + this.getTags(); + }); + } + + getDashboards() { + return this.searchSrv.search(this.query).then((result) => { + return this.initDashboardList(result); + }); + } + + initDashboardList(result: any) { + this.canMove = false; + this.canDelete = false; + this.selectAllChecked = false; + this.hasFilters = this.query.query.length > 0 || this.query.tag.length > 0 || this.query.starred; + + if (!result) { + this.sections = []; + return; + } + + this.sections = result; + + for (let section of this.sections) { + section.checked = false; + + for (let dashboard of section.items) { + dashboard.checked = false; + } + } + + if (this.folderId && this.sections.length > 0) { + this.sections[0].hideHeader = true; + } + } + + selectionChanged() { + let selectedDashboards = 0; + + for (let section of this.sections) { + selectedDashboards += _.filter(section.items, { checked: true }).length; + } + + const selectedFolders = _.filter(this.sections, { checked: true }).length; + this.canMove = selectedDashboards > 0; + this.canDelete = selectedDashboards > 0 || selectedFolders > 0; + } + + getFoldersAndDashboardsToDelete() { + let selectedDashboards = { + folders: [], + dashboards: [] + }; + + for (const section of this.sections) { + if (section.checked && section.id !== 0) { + selectedDashboards.folders.push(section.slug); + } else { + const selected = _.filter(section.items, { checked: true }); + selectedDashboards.dashboards.push(..._.map(selected, 'slug')); + } + } + + return selectedDashboards; + } + + getFolderIds(sections) { + const ids = []; + for (let s of sections) { + if (s.checked) { + ids.push(s.id); + } + } + return ids; + } + + delete() { + const data = this.getFoldersAndDashboardsToDelete(); + const folderCount = data.folders.length; + const dashCount = data.dashboards.length; + let text = 'Do you want to delete the '; + let text2; + + if (folderCount > 0 && dashCount > 0) { + text += `selected folder${folderCount === 1 ? '' : 's'} and dashboard${dashCount === 1 ? '' : 's'}?`; + text2 = `All dashboards of the selected folder${folderCount === 1 ? '' : 's'} will also be deleted`; + } else if (folderCount > 0) { + text += `selected folder${folderCount === 1 ? '' : 's'} and all its dashboards?`; + } else { + text += `selected dashboard${dashCount === 1 ? '' : 's'}?`; + } + + appEvents.emit('confirm-modal', { + title: 'Delete', + text: text, + text2: text2, + icon: 'fa-trash', + yesText: 'Delete', + onConfirm: () => { + const foldersAndDashboards = data.folders.concat(data.dashboards); + this.deleteFoldersAndDashboards(foldersAndDashboards); + } + }); + } + + private deleteFoldersAndDashboards(slugs) { + this.backendSrv.deleteDashboards(slugs).then(result => { + const folders = _.filter(result, dash => dash.meta.isFolder); + const folderCount = folders.length; + const dashboards = _.filter(result, dash => !dash.meta.isFolder); + const dashCount = dashboards.length; + + if (result.length > 0) { + let header; + let msg; + + if (folderCount > 0 && dashCount > 0) { + header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`; + msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `; + msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; + } else if (folderCount > 0) { + header = `Folder${folderCount === 1 ? '' : 's'} Deleted`; + + if (folderCount === 1) { + msg = `${folders[0].dashboard.title} has been deleted`; + } else { + msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`; + } + } else if (dashCount > 0) { + header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`; + + if (dashCount === 1) { + msg = `${dashboards[0].dashboard.title} has been deleted`; + } else { + msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; + } + } + + appEvents.emit('alert-success', [header, msg]); + } + + this.getDashboards(); + }); + } + + getDashboardsToMove() { + let selectedDashboards = []; + + for (const section of this.sections) { + const selected = _.filter(section.items, { checked: true }); + selectedDashboards.push(..._.map(selected, 'slug')); + } + + return selectedDashboards; + } + + moveTo() { + const selectedDashboards = this.getDashboardsToMove(); + + const template = '' + + '`'; + appEvents.emit('show-modal', { + templateHtml: template, + modalClass: 'modal--narrow', + model: { dashboards: selectedDashboards, afterSave: this.getDashboards.bind(this) } + }); + } + + getTags() { + return this.searchSrv.getDashboardTags().then((results) => { + this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results); + this.selectedTagFilter = this.tagFilterOptions[0]; + }); + } + + filterByTag(tag) { + if (_.indexOf(this.query.tag, tag) === -1) { + this.query.tag.push(tag); + } + + return this.getDashboards(); + } + + onQueryChange() { + return this.getDashboards(); + } + + onTagFilterChange() { + var res = this.filterByTag(this.selectedTagFilter.term); + this.selectedTagFilter = this.tagFilterOptions[0]; + return res; + } + + removeTag(tag, evt) { + this.query.tag = _.without(this.query.tag, tag); + this.getDashboards(); + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + } + + removeStarred() { + this.query.starred = false; + return this.getDashboards(); + } + + onStarredFilterChange() { + this.query.starred = this.selectedStarredFilter.text === 'Yes'; + this.selectedStarredFilter = this.starredFilterOptions[0]; + return this.getDashboards(); + } + + onSelectAllChanged() { + for (let section of this.sections) { + if (!section.hideHeader) { + section.checked = this.selectAllChecked; + } + + section.items = _.map(section.items, (item) => { + item.checked = this.selectAllChecked; + return item; + }); + } + + this.selectionChanged(); + } + + clearFilters() { + this.query.query = ''; + this.query.tag = []; + this.query.starred = false; + this.getDashboards(); + } +} + +export function manageDashboardsDirective() { + return { + restrict: 'E', + templateUrl: 'public/app/core/components/manage_dashboards/manage_dashboards.html', + controller: ManageDashboardsCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + folderId: '=' + } + }; +} + +coreModule.directive('manageDashboards', manageDashboardsDirective); diff --git a/public/app/core/components/navbar/navbar.html b/public/app/core/components/navbar/navbar.html index e160d3b3eed..6d611692efc 100644 --- a/public/app/core/components/navbar/navbar.html +++ b/public/app/core/components/navbar/navbar.html @@ -1,43 +1,12 @@ -
    diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts index 94002677de6..79d1a0d7e99 100644 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ b/public/app/core/components/sidemenu/sidemenu.ts @@ -1,105 +1,62 @@ -/// - +import _ from 'lodash'; import config from 'app/core/config'; import $ from 'jquery'; import coreModule from '../../core_module'; +import appEvents from 'app/core/app_events'; export class SideMenuCtrl { - isSignedIn: boolean; - showSignout: boolean; user: any; mainLinks: any; - orgMenu: any; - appSubUrl: string; + bottomNav: any; loginUrl: string; - orgFilter: string; - orgItems: any; - orgs: any; - maxShownOrgs: number; + isSignedIn: boolean; + isOpenMobile: boolean; /** @ngInject */ - constructor(private $scope, private $location, private contextSrv, private backendSrv) { + constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) { this.isSignedIn = contextSrv.isSignedIn; this.user = contextSrv.user; - this.appSubUrl = config.appSubUrl; - this.showSignout = this.contextSrv.isSignedIn && !config['disableSignoutMenu']; - this.maxShownOrgs = 10; - - this.mainLinks = config.bootData.mainNavLinks; - this.openUserDropdown(); + this.mainLinks = _.filter(config.bootData.navTree, item => !item.hideFromMenu); + this.bottomNav = _.filter(config.bootData.navTree, item => item.hideFromMenu); this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path()); - this.$scope.$on('$routeChangeSuccess', () => { - if (!this.contextSrv.pinned) { - this.contextSrv.sidemenu = false; + if (contextSrv.user.orgCount > 1) { + let profileNode = _.find(this.bottomNav, {id: 'profile'}); + if (profileNode) { + profileNode.showOrgSwitcher = true; } + } + + this.$scope.$on('$routeChangeSuccess', () => { this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path()); }); - - this.orgFilter = ''; } - getUrl(url) { - return config.appSubUrl + url; - } + toggleSideMenu() { + this.contextSrv.toggleSideMenu(); + appEvents.emit('toggle-sidemenu'); - openUserDropdown() { - this.orgMenu = [ - {section: 'You', cssClass: 'dropdown-menu-title'}, - {text: 'Profile', url: this.getUrl('/profile')}, - ]; + this.$timeout(() => { + this.$rootScope.$broadcast('render'); + }); + } - if (this.showSignout) { - this.orgMenu.push({text: "Sign out", url: this.getUrl("/logout"), target: "_self"}); - } + toggleSideMenuSmallBreakpoint() { + appEvents.emit('toggle-sidemenu-mobile'); + } - if (this.contextSrv.hasRole('Admin')) { - this.orgMenu.push({section: this.user.orgName, cssClass: 'dropdown-menu-title'}); - this.orgMenu.push({ - text: "Preferences", - url: this.getUrl("/org") - }); - this.orgMenu.push({ - text: "Users", - url: this.getUrl("/org/users") - }); - this.orgMenu.push({ - text: "API Keys", - url: this.getUrl("/org/apikeys") - }); - } + switchOrg() { + this.$rootScope.appEvent('show-modal', { + templateHtml: '', + }); + } - this.orgMenu.push({cssClass: "divider"}); - this.backendSrv.get('/api/user/orgs').then(orgs => { - this.orgs = orgs; - this.loadOrgsItems(); - }); - } - - loadOrgsItems() { - this.orgItems = []; - this.orgs.forEach(org => { - if (org.orgId === this.contextSrv.user.orgId) { - return; - } - - if (this.orgItems.length === this.maxShownOrgs) { - return; - } - - if (this.orgFilter === '' || (org.name.toLowerCase().indexOf(this.orgFilter.toLowerCase()) !== -1)) { - this.orgItems.push({ - text: "Switch to " + org.name, - icon: "fa fa-fw fa-random", - url: this.getUrl('/profile/switch-org/' + org.orgId), - target: '_self' - }); - } - }); - if (config.allowOrgCreate) { - this.orgItems.push({text: "New organization", icon: "fa fa-fw fa-plus", url: this.getUrl('/org/new')}); - } - } + itemClicked(item, evt) { + if (item.url === '/shortcuts') { + appEvents.emit('show-modal', {templateHtml: ''}); + evt.preventDefault(); + } + } } export function sideMenuDirective() { @@ -121,10 +78,6 @@ export function sideMenuDirective() { parent.append(menu); }, 100); }); - - scope.$on("$destory", function() { - elem.off('click.dropdown'); - }); } }; } diff --git a/public/app/core/components/team_picker.ts b/public/app/core/components/team_picker.ts new file mode 100644 index 00000000000..2bf6b3c83b5 --- /dev/null +++ b/public/app/core/components/team_picker.ts @@ -0,0 +1,59 @@ +import coreModule from 'app/core/core_module'; +import _ from 'lodash'; + +const template = ` + +`; +export class TeamPickerCtrl { + group: any; + teamPicked: any; + debouncedSearchGroups: any; + + /** @ngInject */ + constructor(private backendSrv) { + this.debouncedSearchGroups = _.debounce(this.searchGroups, 500, {'leading': true, 'trailing': false}); + this.reset(); + } + + reset() { + this.group = {text: 'Choose', value: null}; + } + + searchGroups(query: string) { + return Promise.resolve(this.backendSrv.get('/api/teams/search?perpage=10&page=1&query=' + query).then(result => { + return _.map(result.teams, ug => { + return {text: ug.name, value: ug}; + }); + })); + } + + onChange(option) { + this.teamPicked({$group: option.value}); + } +} + +export function teamPicker() { + return { + restrict: 'E', + template: template, + controller: TeamPickerCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + teamPicked: '&', + }, + link: function(scope, elem, attrs, ctrl) { + scope.$on("team-picker-reset", () => { + ctrl.reset(); + }); + } + }; +} + +coreModule.directive('teamPicker', teamPicker); diff --git a/public/app/core/components/user_picker.ts b/public/app/core/components/user_picker.ts new file mode 100644 index 00000000000..f6138ba382c --- /dev/null +++ b/public/app/core/components/user_picker.ts @@ -0,0 +1,66 @@ +import coreModule from 'app/core/core_module'; +import _ from 'lodash'; + +const template = ` + +`; +export class UserPickerCtrl { + user: any; + debouncedSearchUsers: any; + userPicked: any; + + /** @ngInject */ + constructor(private backendSrv) { + this.reset(); + this.debouncedSearchUsers = _.debounce(this.searchUsers, 500, {'leading': true, 'trailing': false}); + } + + searchUsers(query: string) { + return Promise.resolve(this.backendSrv.get('/api/users/search?perpage=10&page=1&query=' + query).then(result => { + return _.map(result.users, user => { + return {text: user.login + ' - ' + user.email, value: user}; + }); + })); + } + + onChange(option) { + this.userPicked({$user: option.value}); + } + + reset() { + this.user = {text: 'Choose', value: null}; + } +} + +export interface User { + id: number; + name: string; + login: string; + email: string; +} + +export function userPicker() { + return { + restrict: 'E', + template: template, + controller: UserPickerCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + userPicked: '&', + }, + link: function(scope, elem, attrs, ctrl) { + scope.$on("user-picker-reset", () => { + ctrl.reset(); + }); + } + }; +} + +coreModule.directive('userPicker', userPicker); diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts new file mode 100644 index 00000000000..6e55ce030ed --- /dev/null +++ b/public/app/core/constants.ts @@ -0,0 +1,9 @@ + +export const GRID_CELL_HEIGHT = 30; +export const GRID_CELL_VMARGIN = 10; +export const GRID_COLUMN_COUNT = 24; +export const REPEAT_DIR_VERTICAL = 'v'; + +export const DEFAULT_PANEL_SPAN = 4; +export const DEFAULT_ROW_HEIGHT = 250; +export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; diff --git a/public/app/core/controllers/error_ctrl.ts b/public/app/core/controllers/error_ctrl.ts index fe894a69806..15cd4281a64 100644 --- a/public/app/core/controllers/error_ctrl.ts +++ b/public/app/core/controllers/error_ctrl.ts @@ -1,5 +1,6 @@ import config from 'app/core/config'; import coreModule from '../core_module'; +import appEvents from 'app/core/app_events'; export class ErrorCtrl { @@ -8,11 +9,14 @@ export class ErrorCtrl { $scope.navModel = navModelSrv.getNotFoundNav(); $scope.appSubUrl = config.appSubUrl; - var showSideMenu = contextSrv.sidemenu; - contextSrv.sidemenu = false; + if (!contextSrv.isSignedIn) { + appEvents.emit('toggle-sidemenu-hidden'); + } - $scope.$on('$destroy', function() { - contextSrv.sidemenu = showSideMenu; + $scope.$on("destroy", () => { + if (!contextSrv.isSignedIn) { + appEvents.emit('toggle-sidemenu-hidden'); + } }); } } diff --git a/public/app/core/controllers/invited_ctrl.ts b/public/app/core/controllers/invited_ctrl.ts index ed4bd1793b8..09c7d33b3ab 100644 --- a/public/app/core/controllers/invited_ctrl.ts +++ b/public/app/core/controllers/invited_ctrl.ts @@ -8,6 +8,17 @@ export class InvitedCtrl { contextSrv.sidemenu = false; $scope.formModel = {}; + $scope.navModel = { + main: { + icon: 'gicon gicon-branding', + subTitle: 'Register your Grafana account', + breadcrumbs: [ + { title: 'Login', url: '/login' }, + { title: 'Invite' }, + ] + } + }; + $scope.init = function() { backendSrv.get('/api/user/invite/' + $routeParams.code).then(function(invite) { $scope.formModel.name = invite.name; diff --git a/public/app/core/controllers/reset_password_ctrl.ts b/public/app/core/controllers/reset_password_ctrl.ts index 524cfb7af64..8cd97da1c6f 100644 --- a/public/app/core/controllers/reset_password_ctrl.ts +++ b/public/app/core/controllers/reset_password_ctrl.ts @@ -14,6 +14,17 @@ export class ResetPasswordCtrl { $scope.formModel.code = params.code; } + $scope.navModel = { + main: { + icon: 'gicon gicon-branding', + subTitle: 'Reset your Grafana password', + breadcrumbs: [ + { title: 'Login', url: '/login' }, + { title: 'Reset Password' }, + ] + } + }; + $scope.sendResetEmail = function() { if (!$scope.sendResetForm.$valid) { return; diff --git a/public/app/core/controllers/signup_ctrl.ts b/public/app/core/controllers/signup_ctrl.ts index eb54110eb6d..9db18ad4d4e 100644 --- a/public/app/core/controllers/signup_ctrl.ts +++ b/public/app/core/controllers/signup_ctrl.ts @@ -26,6 +26,17 @@ export class SignUpCtrl { $scope.verifyEmailEnabled = false; $scope.autoAssignOrg = false; + $scope.navModel = { + main: { + icon: 'gicon gicon-branding', + subTitle: 'Register your Grafana account', + breadcrumbs: [ + { title: 'Login', url: '/login' }, + { title: 'Sign Up' }, + ] + } + }; + backendSrv.get('/api/user/signup/options').then(options => { $scope.verifyEmailEnabled = options.verifyEmailEnabled; $scope.autoAssignOrg = options.autoAssignOrg; diff --git a/public/app/core/core.ts b/public/app/core/core.ts index fb92fd81d19..f43f4df3110 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -17,6 +17,8 @@ import './utils/outline'; import './components/colorpicker/ColorPicker'; import './components/colorpicker/SeriesColorPicker'; import './components/colorpicker/spectrum_picker'; +import './services/search_srv'; +import './services/ng_react'; import {grafanaAppDirective} from './components/grafana_app'; import {sideMenuDirective} from './components/sidemenu/sidemenu'; @@ -44,9 +46,20 @@ import {KeybindingSrv} from './services/keybindingSrv'; import {helpModal} from './components/help/help'; import {JsonExplorer} from './components/json_explorer/json_explorer'; import {NavModelSrv, NavModel} from './nav_model_srv'; +import {userPicker} from './components/user_picker'; +import {teamPicker} from './components/team_picker'; +import {geminiScrollbar} from './components/scroll/scroll'; +import {gfPageDirective} from './components/gf_page'; +import {orgSwitcher} from './components/org_switcher'; +import {profiler} from './profiler'; import {registerAngularDirectives} from './angular_wrappers'; +import {updateLegendValues} from './time_series2'; +import TimeSeries from './time_series2'; +import {searchResultsDirective} from './components/search/search_results'; +import {manageDashboardsDirective} from './components/manage_dashboards/manage_dashboards'; export { + profiler, registerAngularDirectives, arrayJoin, coreModule, @@ -71,4 +84,13 @@ export { JsonExplorer, NavModelSrv, NavModel, + userPicker, + teamPicker, + geminiScrollbar, + gfPageDirective, + orgSwitcher, + manageDashboardsDirective, + TimeSeries, + updateLegendValues, + searchResultsDirective }; diff --git a/public/app/core/directives/dash_class.js b/public/app/core/directives/dash_class.js index 08f4d3c7326..9df53bdbd48 100644 --- a/public/app/core/directives/dash_class.js +++ b/public/app/core/directives/dash_class.js @@ -18,21 +18,20 @@ function (_, $, coreModule) { elem.toggleClass('panel-in-fullscreen', false); }); - var lastHideControlsVal; - $scope.$watch('dashboard.hideControls', function() { - if (!$scope.dashboard) { - return; - } - - var hideControls = $scope.dashboard.hideControls; - if (lastHideControlsVal !== hideControls) { - elem.toggleClass('hide-controls', hideControls); - lastHideControlsVal = hideControls; - } + $scope.$watch('ctrl.playlistSrv.isPlaying', function(newValue) { + elem.toggleClass('playlist-active', newValue === true); }); - $scope.$watch('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)); + setTimeout(function() { + elem.toggleClass('dashboard-page--settings-open', _.isString(newValue)); + }, 10); + } else { + elem.removeClass('dashboard-page--settings-opening'); + elem.removeClass('dashboard-page--settings-open'); + } }); } }; diff --git a/public/app/core/directives/dash_edit_link.js b/public/app/core/directives/dash_edit_link.js index d9a439b95c2..f0dc6f59a6a 100644 --- a/public/app/core/directives/dash_edit_link.js +++ b/public/app/core/directives/dash_edit_link.js @@ -2,8 +2,9 @@ define([ 'jquery', 'angular', '../core_module', + 'lodash', ], -function ($, angular, coreModule) { +function ($, angular, coreModule, _) { 'use strict'; var editViewMap = { @@ -12,7 +13,13 @@ function ($, angular, coreModule) { 'templating': { src: 'public/app/features/templating/partials/editor.html'}, 'history': { html: ''}, 'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' }, - 'import': { html: '' } + 'import': { html: '', isModal: true }, + 'permissions': { html: '', isModal: true }, + 'new-folder': { + isModal: true, + html: '', + modalClass: 'modal--narrow' + } }; coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) { @@ -20,6 +27,7 @@ function ($, angular, coreModule) { restrict: 'A', link: function(scope, elem) { var editorScope; + var modalScope; var lastEditView; function hideEditorPane(hideToShowOtherView) { @@ -30,8 +38,7 @@ function ($, angular, coreModule) { function showEditorPane(evt, options) { if (options.editview) { - options.src = editViewMap[options.editview].src; - options.html = editViewMap[options.editview].html; + _.defaults(options, editViewMap[options.editview]); } if (lastEditView && lastEditView === options.editview) { @@ -45,6 +52,11 @@ function ($, angular, coreModule) { editorScope = options.scope ? options.scope.$new() : scope.$new(); editorScope.dismiss = function(hideToShowOtherView) { + if (modalScope) { + modalScope.dismiss(); + modalScope = null; + } + editorScope.$destroy(); lastEditView = null; editorScope = null; @@ -73,16 +85,17 @@ function ($, angular, coreModule) { } }; - if (options.editview === 'import') { - var modalScope = $rootScope.$new(); + if (options.isModal) { + modalScope = $rootScope.$new(); modalScope.$on("$destroy", function() { editorScope.dismiss(); }); $rootScope.appEvent('show-modal', { - templateHtml: '', + templateHtml: options.html, scope: modalScope, - backdrop: 'static' + backdrop: 'static', + modalClass: options.modalClass, }); return; @@ -109,7 +122,7 @@ function ($, angular, coreModule) { }, 10); } - scope.$watch("dashboardViewState.state.editview", function(newValue, oldValue) { + scope.$watch("ctrl.dashboardViewState.state.editview", function(newValue, oldValue) { if (newValue) { showEditorPane(null, {editview: newValue}); } else if (oldValue) { diff --git a/public/app/core/nav_model_srv.ts b/public/app/core/nav_model_srv.ts index dd61db5d346..85d4ccfb7e1 100644 --- a/public/app/core/nav_model_srv.ts +++ b/public/app/core/nav_model_srv.ts @@ -1,224 +1,84 @@ -/// - import coreModule from 'app/core/core_module'; +import config from 'app/core/config'; +import _ from 'lodash'; export interface NavModelItem { - title: string; + text: string; url: string; icon?: string; - iconUrl?: string; + img?: string; + id: string; + active?: boolean; + hideFromTabs?: boolean; + divider?: boolean; + children: NavModelItem[]; + target?: string; } -export interface NavModel { - section: NavModelItem; - menu: NavModelItem[]; +export class NavModel { + breadcrumbs: NavModelItem[]; + main: NavModelItem; + node: NavModelItem; + + constructor() { + this.breadcrumbs = []; + } } export class NavModelSrv { - + navItems: any; /** @ngInject */ - constructor(private contextSrv) { + constructor() { + this.navItems = config.bootData.navTree; } - getAlertingNav(subPage) { - return { - section: { - title: 'Alerting', - url: 'plugins', - icon: 'icon-gf icon-gf-alert' - }, - menu: [ - {title: 'Alert List', active: subPage === 0, url: 'alerting/list', icon: 'fa fa-list-ul'}, - {title: 'Notification channels', active: subPage === 1, url: 'alerting/notifications', icon: 'fa fa-bell-o'}, - ] - }; + getCfgNode() { + return _.find(this.navItems, {id: 'cfg'}); } - getDatasourceNav(subPage) { - return { - section: { - title: 'Data Sources', - url: 'datasources', - icon: 'icon-gf icon-gf-datasources' - }, - menu: [ - {title: 'List view', active: subPage === 0, url: 'datasources', icon: 'fa fa-list-ul'}, - {title: 'Add data source', active: subPage === 1, url: 'datasources/new', icon: 'fa fa-plus'}, - ] - }; - } + getNav(...args) { + var children = this.navItems; + var nav = new NavModel(); - getPlaylistsNav(subPage) { - return { - section: { - title: 'Playlists', - url: 'playlists', - icon: 'fa fa-fw fa-film' - }, - menu: [ - {title: 'List view', active: subPage === 0, url: 'playlists', icon: 'fa fa-list-ul'}, - {title: 'Add Playlist', active: subPage === 1, url: 'playlists/create', icon: 'fa fa-plus'}, - ] - }; - } + for (let id of args) { + // if its a number then it's the index to use for main + if (_.isNumber(id)) { + nav.main = nav.breadcrumbs[id]; + break; + } - getProfileNav() { - return { - section: { - title: 'User Profile', - url: 'profile', - icon: 'fa fa-fw fa-user' - }, - menu: [] - }; + let node = _.find(children, {id: id}); + nav.breadcrumbs.push(node); + nav.node = node; + nav.main = node; + children = node.children; + } + + if (nav.main.children) { + for (let item of nav.main.children) { + item.active = false; + + if (item.url === nav.node.url) { + item.active = true; + } + } + } + + return nav; } getNotFoundNav() { - return { - section: { - title: 'Page', - url: '', - icon: 'fa fa-fw fa-warning' - }, - menu: [] + var node = { + text: "Page not found", + icon: "fa fa-fw fa-warning", + subTitle: "404 Error" }; - } - - getOrgNav(subPage) { - return { - section: { - title: 'Organization', - url: 'org', - icon: 'icon-gf icon-gf-users' - }, - menu: [ - {title: 'Preferences', active: subPage === 0, url: 'org', icon: 'fa fa-fw fa-cog'}, - {title: 'Org Users', active: subPage === 1, url: 'org/users', icon: 'fa fa-fw fa-users'}, - {title: 'API Keys', active: subPage === 2, url: 'org/apikeys', icon: 'fa fa-fw fa-key'}, - ] - }; - } - - getAdminNav(subPage) { - return { - section: { - title: 'Admin', - url: 'admin', - icon: 'fa fa-fw fa-cogs' - }, - menu: [ - {title: 'Users', active: subPage === 0, url: 'admin/users', icon: 'fa fa-fw fa-user'}, - {title: 'Orgs', active: subPage === 1, url: 'admin/orgs', icon: 'fa fa-fw fa-users'}, - {title: 'Server Settings', active: subPage === 2, url: 'admin/settings', icon: 'fa fa-fw fa-cogs'}, - {title: 'Server Stats', active: subPage === 2, url: 'admin/stats', icon: 'fa fa-fw fa-line-chart'}, - {title: 'Style Guide', active: subPage === 2, url: 'styleguide', icon: 'fa fa-fw fa-key'}, - ] - }; - } - - getPluginsNav() { - return { - section: { - title: 'Plugins', - url: 'plugins', - icon: 'icon-gf icon-gf-apps' - }, - menu: [] - }; - } - - getDashboardNav(dashboard, dashNavCtrl) { - // special handling for snapshots - if (dashboard.meta.isSnapshot) { - return { - section: { - title: dashboard.title, - icon: 'icon-gf icon-gf-snapshot' - }, - menu: [ - { - title: 'Go to original dashboard', - icon: 'fa fa-fw fa-external-link', - url: dashboard.snapshot.originalUrl, - } - ] - }; - } - - var menu = []; - - if (dashboard.meta.canEdit) { - menu.push({ - title: 'Settings', - icon: 'fa fa-fw fa-cog', - clickHandler: () => dashNavCtrl.openEditView('settings') - }); - - menu.push({ - title: 'Templating', - icon: 'fa fa-fw fa-code', - clickHandler: () => dashNavCtrl.openEditView('templating') - }); - - menu.push({ - title: 'Annotations', - icon: 'fa fa-fw fa-comment', - clickHandler: () => dashNavCtrl.openEditView('annotations') - }); - - if (!dashboard.meta.isHome) { - menu.push({ - title: 'Version history', - icon: 'fa fa-fw fa-history', - clickHandler: () => dashNavCtrl.openEditView('history') - }); - } - - menu.push({ - title: 'View JSON', - icon: 'fa fa-fw fa-eye', - clickHandler: () => dashNavCtrl.viewJson() - }); - } - - if (this.contextSrv.isEditor && !dashboard.editable) { - menu.push({ - title: 'Make Editable', - icon: 'fa fa-fw fa-edit', - clickHandler: () => dashNavCtrl.makeEditable() - }); - } - - menu.push({ - title: 'Shortcuts', - icon: 'fa fa-fw fa-keyboard-o', - clickHandler: () => dashNavCtrl.showHelpModal() - }); - - if (this.contextSrv.isEditor) { - menu.push({ - title: 'Save As ...', - icon: 'fa fa-fw fa-save', - clickHandler: () => dashNavCtrl.saveDashboardAs() - }); - } - - if (dashboard.meta.canSave) { - menu.push({ - title: 'Delete', - icon: 'fa fa-fw fa-trash', - clickHandler: () => dashNavCtrl.deleteDashboard() - }); - - } return { - section: { - title: dashboard.title, - icon: 'icon-gf icon-gf-dashboard' - }, - menu: menu + breadcrumbs: [node], + node: node, + main: node }; } } diff --git a/public/app/core/routes/dashboard_loaders.ts b/public/app/core/routes/dashboard_loaders.ts index 14e093d5169..4e32a378a3b 100644 --- a/public/app/core/routes/dashboard_loaders.ts +++ b/public/app/core/routes/dashboard_loaders.ts @@ -20,6 +20,9 @@ export class LoadDashboardCtrl { } dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) { + if ($routeParams.keepRows) { + result.meta.keepRows = true; + } $scope.initDashboard(result, $scope); }); } @@ -28,19 +31,19 @@ export class LoadDashboardCtrl { export class NewDashboardCtrl { /** @ngInject */ - constructor($scope) { + constructor($scope, $routeParams) { $scope.initDashboard({ meta: { canStar: false, canShare: false, isNew: true }, dashboard: { title: "New dashboard", - rows: [ + panels: [ { - title: 'Dashboard Row', - height: '250px', - panels: [], - isNew: true, + type: 'add-panel', + gridPos: {x: 0, y: 0, w: 12, h: 9}, + title: 'Panel Title', } - ] + ], + folderId: Number($routeParams.folderId) }, }, $scope); } diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index 1f9dbcae57c..cd1ae8682b4 100644 --- a/public/app/core/routes/routes.ts +++ b/public/app/core/routes/routes.ts @@ -48,9 +48,10 @@ function setupAngularRoutes($routeProvider, $locationProvider) { reloadOnSearch: false, pageClass: 'page-dashboard', }) - .when('/dashboards/list', { - templateUrl: 'public/app/features/dashboard/partials/dash_list.html', - controller : 'DashListCtrl', + .when('/dashboard/import', { + templateUrl: 'public/app/features/dashboard/partials/dashboardImport.html', + controller : 'DashboardImportCtrl', + controllerAs: 'ctrl', }) .when('/datasources', { templateUrl: 'public/app/features/plugins/partials/ds_list.html', @@ -67,6 +68,31 @@ function setupAngularRoutes($routeProvider, $locationProvider) { controller : 'DataSourceEditCtrl', controllerAs: 'ctrl', }) + .when('/dashboards', { + templateUrl: 'public/app/features/dashboard/partials/dashboard_list.html', + controller : 'DashboardListCtrl', + controllerAs: 'ctrl', + }) + .when('/dashboards/folder/new', { + templateUrl: 'public/app/features/dashboard/partials/create_folder.html', + controller : 'CreateFolderCtrl', + controllerAs: 'ctrl', + }) + .when('/dashboards/folder/:folderId/:slug/permissions', { + templateUrl: 'public/app/features/dashboard/partials/folder_permissions.html', + controller : 'FolderPermissionsCtrl', + controllerAs: 'ctrl', + }) + .when('/dashboards/folder/:folderId/:slug/settings', { + templateUrl: 'public/app/features/dashboard/partials/folder_settings.html', + controller : 'FolderSettingsCtrl', + controllerAs: 'ctrl', + }) + .when('/dashboards/folder/:folderId/:slug', { + templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html', + controller : 'FolderDashboardsCtrl', + controllerAs: 'ctrl', + }) .when('/org', { templateUrl: 'public/app/features/org/partials/orgDetails.html', controller : 'OrgDetailsCtrl', @@ -83,11 +109,29 @@ function setupAngularRoutes($routeProvider, $locationProvider) { controllerAs: 'ctrl', resolve: loadOrgBundle, }) + .when('/org/users/invite', { + templateUrl: 'public/app/features/org/partials/invite.html', + controller : 'UserInviteCtrl', + controllerAs: 'ctrl', + resolve: loadOrgBundle, + }) .when('/org/apikeys', { templateUrl: 'public/app/features/org/partials/orgApiKeys.html', controller : 'OrgApiKeysCtrl', resolve: loadOrgBundle, }) + .when('/org/teams', { + templateUrl: 'public/app/features/org/partials/teams.html', + controller : 'TeamsCtrl', + controllerAs: 'ctrl', + resolve: loadOrgBundle, + }) + .when('/org/teams/edit/:id', { + templateUrl: 'public/app/features/org/partials/team_details.html', + controller : 'TeamDetailsCtrl', + controllerAs: 'ctrl', + resolve: loadOrgBundle, + }) .when('/profile', { templateUrl: 'public/app/features/org/partials/profile.html', controller : 'ProfileCtrl', @@ -155,22 +199,27 @@ function setupAngularRoutes($routeProvider, $locationProvider) { .when('/login', { templateUrl: 'public/app/partials/login.html', controller : 'LoginCtrl', + pageClass: 'login-page sidemenu-hidden', }) .when('/invite/:code', { templateUrl: 'public/app/partials/signup_invited.html', controller : 'InvitedCtrl', + pageClass: 'sidemenu-hidden', }) .when('/signup', { templateUrl: 'public/app/partials/signup_step2.html', controller : 'SignUpCtrl', + pageClass: 'sidemenu-hidden', }) .when('/user/password/send-reset-email', { templateUrl: 'public/app/partials/reset_password.html', controller : 'ResetPasswordCtrl', + pageClass: 'sidemenu-hidden', }) .when('/user/password/reset', { templateUrl: 'public/app/partials/reset_password.html', controller : 'ResetPasswordCtrl', + pageClass: 'sidemenu-hidden', }) .when('/dashboard/snapshots', { templateUrl: 'public/app/features/snapshot/partials/snapshots.html', diff --git a/public/app/core/services/all.js b/public/app/core/services/all.js index a308febb219..0053d789cbe 100644 --- a/public/app/core/services/all.js +++ b/public/app/core/services/all.js @@ -8,5 +8,6 @@ define([ './segment_srv', './backend_srv', './dynamic_directive_srv', + './global_event_srv' ], function () {}); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 544e6c60de8..f9cc0ae124f 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -3,6 +3,7 @@ import _ from 'lodash'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; +import { DashboardModel } from 'app/features/dashboard/dashboard_model'; export class BackendSrv { private inFlightRequests = {}; @@ -227,10 +228,127 @@ export class BackendSrv { return this.post('/api/dashboards/db/', { dashboard: dash, + folderId: dash.folderId, overwrite: options.overwrite === true, message: options.message || '', }); } + + createDashboardFolder(name) { + const dash = { + schemaVersion: 16, + title: name, + editable: true, + panels: [] + }; + + return this.post('/api/dashboards/db/', {dashboard: dash, isFolder: true, overwrite: false}) + .then(res => { + return this.getDashboard('db', res.slug); + }); + } + + deleteDashboard(slug) { + let deferred = this.$q.defer(); + + this.getDashboard('db', slug) + .then(fullDash => { + this.delete(`/api/dashboards/db/${slug}`) + .then(() => { + deferred.resolve(fullDash); + }).catch(err => { + deferred.reject(err); + }); + }); + + return deferred.promise; + } + + deleteDashboards(dashboardSlugs) { + const tasks = []; + + for (let slug of dashboardSlugs) { + tasks.push(this.createTask(this.deleteDashboard.bind(this), true, slug)); + } + + return this.executeInOrder(tasks, []); + } + + moveDashboards(dashboardSlugs, toFolder) { + const tasks = []; + + for (let slug of dashboardSlugs) { + tasks.push(this.createTask(this.moveDashboard.bind(this), true, slug, toFolder)); + } + + return this.executeInOrder(tasks, []) + .then(result => { + return { + totalCount: result.length, + successCount: _.filter(result, { succeeded: true }).length, + alreadyInFolderCount: _.filter(result, { alreadyInFolder: true }).length + }; + }); + } + + private moveDashboard(slug, toFolder) { + let deferred = this.$q.defer(); + + this.getDashboard('db', slug).then(fullDash => { + const model = new DashboardModel(fullDash.dashboard, fullDash.meta); + + if ((!fullDash.meta.folderId && toFolder.id === 0) || + fullDash.meta.folderId === toFolder.id) { + deferred.resolve({alreadyInFolder: true}); + return; + } + + model.folderId = toFolder.id; + model.meta.folderId = toFolder.id; + model.meta.folderTitle = toFolder.title; + const clone = model.getSaveModelClone(); + + this.saveDashboard(clone, {}) + .then(() => { + deferred.resolve({succeeded: true}); + }).catch(err => { + if (err.data && err.data.status === "plugin-dashboard") { + err.isHandled = true; + + this.saveDashboard(clone, {overwrite: true}) + .then(() => { + deferred.resolve({succeeded: true}); + }).catch(err => { + deferred.resolve({succeeded: false}); + }); + } else { + deferred.resolve({succeeded: false}); + } + }); + }); + + return deferred.promise; + } + + private createTask(fn, ignoreRejections, ...args: any[]) { + return (result) => { + return fn.apply(null, args) + .then(res => { + return Array.prototype.concat(result, [res]); + }).catch(err => { + if (ignoreRejections) { + return result; + } + + throw err; + }); + }; + } + + private executeInOrder(tasks, initialValue) { + return tasks.reduce(this.$q.when, initialValue); + } } + coreModule.service('backendSrv', BackendSrv); diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index ddff1093720..d707afc283d 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -1,5 +1,3 @@ -/// - import config from 'app/core/config'; import _ from 'lodash'; import coreModule from 'app/core/core_module'; @@ -9,6 +7,7 @@ export class User { isGrafanaAdmin: any; isSignedIn: any; orgRole: any; + orgId: number; timezone: string; helpFlags1: number; lightTheme: boolean; @@ -28,12 +27,10 @@ export class ContextSrv { isGrafanaAdmin: any; isEditor: any; sidemenu: any; + sidemenuSmallBreakpoint = false; constructor() { - this.pinned = store.getBool('grafana.sidemenu.pinned', false); - if (this.pinned) { - this.sidemenu = true; - } + this.sidemenu = store.getBool('grafana.sidemenu', true); if (!config.buildInfo) { config.buildInfo = {}; @@ -53,20 +50,13 @@ export class ContextSrv { return this.user.orgRole === role; } - setPinnedState(val) { - this.pinned = val; - store.set('grafana.sidemenu.pinned', val); - } - isGrafanaVisible() { return !!(document.visibilityState === undefined || document.visibilityState === 'visible'); } toggleSideMenu() { this.sidemenu = !this.sidemenu; - if (!this.sidemenu) { - this.setPinnedState(false); - } + store.set('grafana.sidemenu', this.sidemenu); } } diff --git a/public/app/core/services/global_event_srv.ts b/public/app/core/services/global_event_srv.ts new file mode 100644 index 00000000000..a4d5865eb63 --- /dev/null +++ b/public/app/core/services/global_event_srv.ts @@ -0,0 +1,21 @@ +import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; + +// This service is for registering global events. +// Good for communication react > angular and vice verse +export class GlobalEventSrv { + + /** @ngInject */ + constructor(private $location, private $timeout) { + } + + init() { + appEvents.on('location-change', payload => { + this.$timeout(() => { // A hack to use timeout when we're changing things (in this case the url) from outside of Angular. + this.$location.path(payload.href); + }); + }); + } +} + +coreModule.service('globalEventSrv', GlobalEventSrv); diff --git a/public/app/features/dashboard/impression_store.ts b/public/app/core/services/impression_srv.ts similarity index 90% rename from public/app/features/dashboard/impression_store.ts rename to public/app/core/services/impression_srv.ts index 68478aef09a..9d2613d7372 100644 --- a/public/app/features/dashboard/impression_store.ts +++ b/public/app/core/services/impression_srv.ts @@ -2,7 +2,7 @@ import store from 'app/core/store'; import _ from 'lodash'; import config from 'app/core/config'; -export class ImpressionsStore { +export class ImpressionSrv { constructor() {} addDashboardImpression(dashboardId) { @@ -44,8 +44,5 @@ export class ImpressionsStore { } } -var impressions = new ImpressionsStore(); - -export { - impressions -}; +const impressionSrv = new ImpressionSrv(); +export default impressionSrv; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index a8f36afc468..4e819abd1bc 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -1,5 +1,3 @@ -/// - import $ from 'jquery'; import _ from 'lodash'; @@ -74,8 +72,8 @@ export class KeybindingSrv { }, 'keydown'); } - showDashEditView(view) { - var search = _.extend(this.$location.search(), {editview: view}); + showDashEditView() { + var search = _.extend(this.$location.search(), {editview: 'settings'}); this.$location.search(search); } @@ -83,11 +81,7 @@ export class KeybindingSrv { this.bind('mod+o', () => { dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; appEvents.emit('graph-hover-clear'); - scope.broadcastRefresh(); - }); - - this.bind('mod+h', () => { - dashboard.hideControls = !dashboard.hideControls; + this.$rootScope.$broadcast('refresh'); }); this.bind('mod+s', e => { @@ -195,11 +189,11 @@ export class KeybindingSrv { }); this.bind('d r', () => { - scope.broadcastRefresh(); + this.$rootScope.$broadcast('refresh'); }); this.bind('d s', () => { - this.showDashEditView('settings'); + this.showDashEditView(); }); this.bind('d k', () => { @@ -217,8 +211,14 @@ export class KeybindingSrv { } scope.appEvent('hide-modal'); - scope.appEvent('hide-dash-editor'); scope.appEvent('panel-change-view', {fullscreen: false, edit: false}); + + // close settings view + var search = this.$location.search(); + if (search.editview) { + delete search.editview; + this.$location.search(search); + } }); } } diff --git a/public/app/core/services/ng_react.ts b/public/app/core/services/ng_react.ts new file mode 100644 index 00000000000..9922772c730 --- /dev/null +++ b/public/app/core/services/ng_react.ts @@ -0,0 +1,301 @@ +// +// This is using ng-react with this PR applied https://github.com/ngReact/ngReact/pull/199 +// + + +// # ngReact +// ### Use React Components inside of your Angular applications +// +// Composed of +// - reactComponent (generic directive for delegating off to React Components) +// - reactDirective (factory for creating specific directives that correspond to reactComponent directives) + +import React from 'react'; +import ReactDOM from 'react-dom'; +import angular from 'angular'; + +// get a react component from name (components can be an angular injectable e.g. value, factory or +// available on window +function getReactComponent(name, $injector) { + // if name is a function assume it is component and return it + if (angular.isFunction(name)) { + return name; + } + + // a React component name must be specified + if (!name) { + throw new Error('ReactComponent name attribute must be specified'); + } + + // ensure the specified React component is accessible, and fail fast if it's not + var reactComponent; + try { + reactComponent = $injector.get(name); + } catch (e) {} + + if (!reactComponent) { + try { + reactComponent = name.split('.').reduce(function(current, namePart) { + return current[namePart]; + }, window); + } catch (e) {} + } + + if (!reactComponent) { + throw Error('Cannot find react component ' + name); + } + + return reactComponent; +} + +// wraps a function with scope.$apply, if already applied just return +function applied(fn, scope) { + if (fn.wrappedInApply) { + return fn; + } + var wrapped: any = function() { + var args = arguments; + var phase = scope.$root.$$phase; + if (phase === '$apply' || phase === '$digest') { + return fn.apply(null, args); + } else { + return scope.$apply(function() { + return fn.apply(null, args); + }); + } + }; + wrapped.wrappedInApply = true; + return wrapped; +} + +/** + * wraps functions on obj in scope.$apply + * + * keeps backwards compatibility, as if propsConfig is not passed, it will + * work as before, wrapping all functions and won't wrap only when specified. + * + * @version 0.4.1 + * @param obj react component props + * @param scope current scope + * @param propsConfig configuration object for all properties + * @returns {Object} props with the functions wrapped in scope.$apply + */ +function applyFunctions(obj, scope, propsConfig?) { + return Object.keys(obj || {}).reduce(function(prev, key) { + var value = obj[key]; + var config = (propsConfig || {})[key] || {}; + /** + * wrap functions in a function that ensures they are scope.$applied + * ensures that when function is called from a React component + * the Angular digest cycle is run + */ + prev[key] = angular.isFunction(value) && config.wrapApply !== false ? applied(value, scope) : value; + + return prev; + }, {}); +} + +/** + * + * @param watchDepth (value of HTML watch-depth attribute) + * @param scope (angular scope) + * + * Uses the watchDepth attribute to determine how to watch props on scope. + * If watchDepth attribute is NOT reference or collection, watchDepth defaults to deep watching by value + */ +function watchProps(watchDepth, scope, watchExpressions, listener) { + var supportsWatchCollection = angular.isFunction(scope.$watchCollection); + var supportsWatchGroup = angular.isFunction(scope.$watchGroup); + + var watchGroupExpressions = []; + + watchExpressions.forEach(function(expr) { + var actualExpr = getPropExpression(expr); + var exprWatchDepth = getPropWatchDepth(watchDepth, expr); + + if (exprWatchDepth === 'collection' && supportsWatchCollection) { + scope.$watchCollection(actualExpr, listener); + } else if (exprWatchDepth === 'reference' && supportsWatchGroup) { + watchGroupExpressions.push(actualExpr); + } else if (exprWatchDepth === 'one-time') { + //do nothing because we handle our one time bindings after this + } else { + scope.$watch(actualExpr, listener, exprWatchDepth !== 'reference'); + } + }); + + if (watchDepth === 'one-time') { + listener(); + } + + if (watchGroupExpressions.length) { + scope.$watchGroup(watchGroupExpressions, listener); + } +} + +// render React component, with scope[attrs.props] being passed in as the component props +function renderComponent(component, props, scope, elem) { + scope.$evalAsync(function() { + ReactDOM.render(React.createElement(component, props), elem[0]); + }); +} + +// get prop name from prop (string or array) +function getPropName(prop) { + return Array.isArray(prop) ? prop[0] : prop; +} + +// get prop name from prop (string or array) +function getPropConfig(prop) { + return Array.isArray(prop) ? prop[1] : {}; +} + +// get prop expression from prop (string or array) +function getPropExpression(prop) { + return Array.isArray(prop) ? prop[0] : prop; +} + +// find the normalized attribute knowing that React props accept any type of capitalization +function findAttribute(attrs, propName) { + var index = Object.keys(attrs).filter(function(attr) { + return attr.toLowerCase() === propName.toLowerCase(); + })[0]; + return attrs[index]; +} + +// get watch depth of prop (string or array) +function getPropWatchDepth(defaultWatch, prop) { + var customWatchDepth = Array.isArray(prop) && angular.isObject(prop[1]) && prop[1].watchDepth; + return customWatchDepth || defaultWatch; +} + +// # reactComponent +// Directive that allows React components to be used in Angular templates. +// +// Usage: +// +// +// This requires that there exists an injectable or globally available 'Hello' React component. +// The 'props' attribute is optional and is passed to the component. +// +// The following would would create and register the component: +// +// var module = angular.module('ace.react.components'); +// module.value('Hello', React.createClass({ +// render: function() { +// return
    Hello {this.props.name}
    ; +// } +// })); +// +var reactComponent = function($injector) { + return { + restrict: 'E', + replace: true, + link: function(scope, elem, attrs) { + var reactComponent = getReactComponent(attrs.name, $injector); + + var renderMyComponent = function() { + var scopeProps = scope.$eval(attrs.props); + var props = applyFunctions(scopeProps, scope); + + renderComponent(reactComponent, props, scope, elem); + }; + + // If there are props, re-render when they change + attrs.props ? watchProps(attrs.watchDepth, scope, [attrs.props], renderMyComponent) : renderMyComponent(); + + // cleanup when scope is destroyed + scope.$on('$destroy', function() { + if (!attrs.onScopeDestroy) { + ReactDOM.unmountComponentAtNode(elem[0]); + } else { + scope.$eval(attrs.onScopeDestroy, { + unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0]), + }); + } + }); + }, + }; +}; + +// # reactDirective +// Factory function to create directives for React components. +// +// With a component like this: +// +// var module = angular.module('ace.react.components'); +// module.value('Hello', React.createClass({ +// render: function() { +// return
    Hello {this.props.name}
    ; +// } +// })); +// +// A directive can be created and registered with: +// +// module.directive('hello', function(reactDirective) { +// return reactDirective('Hello', ['name']); +// }); +// +// Where the first argument is the injectable or globally accessible name of the React component +// and the second argument is an array of property names to be watched and passed to the React component +// as props. +// +// This directive can then be used like this: +// +// +// +var reactDirective = function($injector) { + return function(reactComponentName, props, conf, injectableProps) { + var directive = { + restrict: 'E', + replace: true, + link: function(scope, elem, attrs) { + var reactComponent = getReactComponent(reactComponentName, $injector); + + // if props is not defined, fall back to use the React component's propTypes if present + props = props || Object.keys(reactComponent.propTypes || {}); + + // for each of the properties, get their scope value and set it to scope.props + var renderMyComponent = function() { + var scopeProps = {}, + config = {}; + + props.forEach(function(prop) { + var propName = getPropName(prop); + scopeProps[propName] = scope.$eval(findAttribute(attrs, propName)); + config[propName] = getPropConfig(prop); + }); + + scopeProps = applyFunctions(scopeProps, scope, config); + scopeProps = angular.extend({}, scopeProps, injectableProps); + renderComponent(reactComponent, scopeProps, scope, elem); + }; + + // watch each property name and trigger an update whenever something changes, + // to update scope.props with new values + var propExpressions = props.map(function(prop) { + return Array.isArray(prop) ? [attrs[getPropName(prop)], getPropConfig(prop)] : attrs[prop]; + }); + + // If we don't have any props, then our watch statement won't fire. + props.length ? watchProps(attrs.watchDepth, scope, propExpressions, renderMyComponent) : renderMyComponent(); + + // cleanup when scope is destroyed + scope.$on('$destroy', function() { + if (!attrs.onScopeDestroy) { + ReactDOM.unmountComponentAtNode(elem[0]); + } else { + scope.$eval(attrs.onScopeDestroy, { + unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0]), + }); + } + }); + }, + }; + return angular.extend(directive, conf); + }; +}; + +let ngModule = angular.module('react', []); +ngModule.directive('reactComponent', ['$injector', reactComponent]); +ngModule.factory('reactDirective', ['$injector', reactDirective]); diff --git a/public/app/core/services/popover_srv.ts b/public/app/core/services/popover_srv.ts index bfbd9e9950e..bf0dae2c631 100644 --- a/public/app/core/services/popover_srv.ts +++ b/public/app/core/services/popover_srv.ts @@ -68,6 +68,13 @@ function popoverSrv($compile, $rootScope, $timeout) { openDrop = drop; openDrop.open(); }, 100); + + // return close function + return function() { + if (drop) { + drop.close(); + } + }; }; } diff --git a/public/app/core/services/search_srv.ts b/public/app/core/services/search_srv.ts new file mode 100644 index 00000000000..a930868852f --- /dev/null +++ b/public/app/core/services/search_srv.ts @@ -0,0 +1,205 @@ +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import impressionSrv from 'app/core/services/impression_srv'; +import store from 'app/core/store'; +import { contextSrv } from 'app/core/services/context_srv'; + +export class SearchSrv { + recentIsOpen: boolean; + starredIsOpen: boolean; + + /** @ngInject */ + constructor(private backendSrv, private $q) { + this.recentIsOpen = store.getBool('search.sections.recent', true); + this.starredIsOpen = store.getBool('search.sections.starred', true); + } + + private getRecentDashboards(sections) { + return this.queryForRecentDashboards().then(result => { + if (result.length > 0) { + sections['recent'] = { + title: 'Recent Boards', + icon: 'fa fa-clock-o', + score: -1, + removable: true, + expanded: this.recentIsOpen, + toggle: this.toggleRecent.bind(this), + items: result, + }; + } + }); + } + + private queryForRecentDashboards() { + var dashIds = _.take(impressionSrv.getDashboardOpened(), 5); + if (dashIds.length === 0) { + return Promise.resolve([]); + } + + return this.backendSrv.search({ dashboardIds: dashIds }).then(result => { + return dashIds.map(orderId => { + return _.find(result, { id: orderId }); + }).filter(hit => hit && !hit.isStarred) + .map(hit => { + return this.transformToViewModel(hit); + }); + }); + } + + private toggleRecent(section) { + this.recentIsOpen = section.expanded = !section.expanded; + store.set('search.sections.recent', this.recentIsOpen); + + if (!section.expanded || section.items.length) { + return Promise.resolve(section); + } + + return this.queryForRecentDashboards().then(result => { + section.items = result; + return Promise.resolve(section); + }); + } + + private toggleStarred(section) { + this.starredIsOpen = section.expanded = !section.expanded; + store.set('search.sections.starred', this.starredIsOpen); + return Promise.resolve(section); + } + + private getStarred(sections) { + if (!contextSrv.isSignedIn) { + return Promise.resolve(); + } + + return this.backendSrv.search({starred: true, limit: 5}).then(result => { + if (result.length > 0) { + sections['starred'] = { + title: 'Starred Boards', + icon: 'fa fa-star-o', + score: -2, + expanded: this.starredIsOpen, + toggle: this.toggleStarred.bind(this), + items: result.map(this.transformToViewModel), + }; + } + }); + } + + private transformToViewModel(hit) { + hit.url = 'dashboard/db/' + hit.slug; + return hit; + } + + search(options) { + let sections: any = {}; + let promises = []; + let query = _.clone(options); + let hasFilters = options.query || + (options.tag && options.tag.length > 0) || options.starred || + (options.folderIds && options.folderIds.length > 0); + + if (!options.skipRecent && !hasFilters) { + promises.push(this.getRecentDashboards(sections)); + } + + if (!options.skipStarred && !hasFilters) { + promises.push(this.getStarred(sections)); + } + + query.folderIds = query.folderIds || []; + if (!hasFilters) { + query.folderIds = [0]; + } + + promises.push(this.backendSrv.search(query).then(results => { + return this.handleSearchResult(sections, results); + })); + + return this.$q.all(promises).then(() => { + return _.sortBy(_.values(sections), 'score'); + }); + } + + private handleSearchResult(sections, results) { + if (results.length === 0) { + return sections; + } + + // create folder index + for (let hit of results) { + if (hit.type === 'dash-folder') { + sections[hit.id] = { + id: hit.id, + title: hit.title, + expanded: false, + items: [], + toggle: this.toggleFolder.bind(this), + url: `dashboards/folder/${hit.id}/${hit.slug}`, + slug: hit.slug, + icon: 'fa fa-folder', + score: _.keys(sections).length, + }; + } + } + + for (let hit of results) { + if (hit.type === 'dash-folder') { + continue; + } + + let section = sections[hit.folderId || 0]; + if (!section) { + if (hit.folderId) { + section = { + id: hit.folderId, + title: hit.folderTitle, + url: `dashboards/folder/${hit.folderId}/${hit.folderSlug}`, + slug: hit.slug, + items: [], + icon: 'fa fa-folder-open', + toggle: this.toggleFolder.bind(this), + score: _.keys(sections).length, + }; + } else { + section = { + id: 0, + title: 'Root', + items: [], + icon: 'fa fa-folder-open', + toggle: this.toggleFolder.bind(this), + score: _.keys(sections).length, + }; + } + // add section + sections[hit.folderId || 0] = section; + } + + section.expanded = true; + section.items.push(this.transformToViewModel(hit)); + } + } + + private toggleFolder(section) { + section.expanded = !section.expanded; + section.icon = section.expanded ? 'fa fa-folder-open' : 'fa fa-folder'; + + if (section.items.length) { + return Promise.resolve(section); + } + + let query = { + folderIds: [section.id], + }; + + return this.backendSrv.search(query).then(results => { + section.items = _.map(results, this.transformToViewModel); + return Promise.resolve(section); + }); + } + + getDashboardTags() { + return this.backendSrv.get('/api/dashboards/tags'); + } +} + +coreModule.service('searchSrv', SearchSrv); diff --git a/public/app/core/specs/backend_srv_specs.ts b/public/app/core/specs/backend_srv_specs.ts index 0e78007f210..5e8cfa7b761 100644 --- a/public/app/core/specs/backend_srv_specs.ts +++ b/public/app/core/specs/backend_srv_specs.ts @@ -3,14 +3,12 @@ import 'app/core/services/backend_srv'; describe('backend_srv', function() { var _backendSrv; - var _http; var _httpBackend; beforeEach(angularMocks.module('grafana.core')); beforeEach(angularMocks.module('grafana.services')); beforeEach(angularMocks.inject(function ($httpBackend, $http, backendSrv) { _httpBackend = $httpBackend; - _http = $http; _backendSrv = backendSrv; })); diff --git a/public/app/core/specs/datemath.jest.ts b/public/app/core/specs/datemath.jest.ts index e2bdebcebce..54ef711bdcf 100644 --- a/public/app/core/specs/datemath.jest.ts +++ b/public/app/core/specs/datemath.jest.ts @@ -84,12 +84,10 @@ describe("DateMath", () => { describe('rounding', () => { var now; - var anchored; beforeEach(() => { clock = sinon.useFakeTimers(unix); now = moment(); - anchored = moment(anchor); }); _.each(spans, (span) => { diff --git a/public/app/core/specs/manage_dashboards.jest.ts b/public/app/core/specs/manage_dashboards.jest.ts new file mode 100644 index 00000000000..00e1d0b11bd --- /dev/null +++ b/public/app/core/specs/manage_dashboards.jest.ts @@ -0,0 +1,607 @@ +import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards'; +import { SearchSrv } from 'app/core/services/search_srv'; +import q from 'q'; + +describe('ManageDashboards', () => { + let ctrl; + + describe('when browsing dashboards', () => { + beforeEach(() => { + const response = [ + { + id: 410, + title: "afolder", + type: "dash-folder", + items: [ + { + id: 399, + title: "Dashboard Test", + url: "dashboard/db/dashboard-test", + icon: 'fa fa-folder', + tags: [], + isStarred: false, + folderId: 410, + folderTitle: "afolder", + folderSlug: "afolder" + } + ], + tags: [], + isStarred: false + }, + { + id: 0, + title: "Root", + icon: 'fa fa-folder-open', + uri: "db/something-else", + type: "dash-db", + items: [ + { + id: 500, + title: "Dashboard Test", + url: "dashboard/db/dashboard-test", + icon: 'fa fa-folder', + tags: [], + isStarred: false + } + ], + tags: [], + isStarred: false, + } + ]; + ctrl = createCtrlWithStubs(response); + return ctrl.getDashboards(); + }); + + it('should set checked to false on all sections and children', () => { + expect(ctrl.sections.length).toEqual(2); + expect(ctrl.sections[0].checked).toEqual(false); + expect(ctrl.sections[0].items[0].checked).toEqual(false); + expect(ctrl.sections[1].checked).toEqual(false); + expect(ctrl.sections[1].items[0].checked).toEqual(false); + expect(ctrl.sections[0].hideHeader).toBeFalsy(); + }); + }); + + describe('when browsing dashboards for a folder', () => { + beforeEach(() => { + const response = [ + { + id: 410, + title: "afolder", + type: "dash-folder", + items: [ + { + id: 399, + title: "Dashboard Test", + url: "dashboard/db/dashboard-test", + icon: 'fa fa-folder', + tags: [], + isStarred: false, + folderId: 410, + folderTitle: "afolder", + folderSlug: "afolder" + } + ], + tags: [], + isStarred: false + } + ]; + ctrl = createCtrlWithStubs(response); + ctrl.folderId = 410; + return ctrl.getDashboards(); + }); + + it('should set hide header to true on section', () => { + expect(ctrl.sections[0].hideHeader).toBeTruthy(); + }); + }); + + describe('when searching dashboards', () => { + beforeEach(() => { + const response = [ + { + checked: false, + expanded: true, + hideHeader: true, + items: [ + { + id: 399, + title: "Dashboard Test", + url: "dashboard/db/dashboard-test", + icon: 'fa fa-folder', + tags: [], + isStarred: false, + folderId: 410, + folderTitle: "afolder", + folderSlug: "afolder" + }, + { + id: 500, + title: "Dashboard Test", + url: "dashboard/db/dashboard-test", + icon: 'fa fa-folder', + tags: [], + folderId: 499, + isStarred: false + } + ] + } + ]; + + ctrl = createCtrlWithStubs(response); + }); + + describe('with query filter', () => { + beforeEach(() => { + ctrl.query.query = 'd'; + ctrl.canMove = true; + ctrl.canDelete = true; + ctrl.selectAllChecked = true; + return ctrl.getDashboards(); + }); + + it('should set checked to false on all sections and children', () => { + expect(ctrl.sections.length).toEqual(1); + expect(ctrl.sections[0].checked).toEqual(false); + expect(ctrl.sections[0].items[0].checked).toEqual(false); + expect(ctrl.sections[0].items[1].checked).toEqual(false); + }); + + it('should uncheck select all', () => { + expect(ctrl.selectAllChecked).toBeFalsy(); + }); + + it('should disable Move To button', () => { + expect(ctrl.canMove).toBeFalsy(); + }); + + it('should disable delete button', () => { + expect(ctrl.canDelete).toBeFalsy(); + }); + + it('should have active filters', () => { + expect(ctrl.hasFilters).toBeTruthy(); + }); + + describe('when select all is checked', () => { + beforeEach(() => { + ctrl.selectAllChecked = true; + ctrl.onSelectAllChanged(); + }); + + it('should select all dashboards', () => { + expect(ctrl.sections[0].checked).toBeFalsy(); + expect(ctrl.sections[0].items[0].checked).toBeTruthy(); + expect(ctrl.sections[0].items[1].checked).toBeTruthy(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + + describe('when clearing filters', () => { + beforeEach(() => { + return ctrl.clearFilters(); + }); + + it('should reset query filter', () => { + expect(ctrl.query.query).toEqual(''); + }); + }); + }); + }); + + describe('with tag filter', () => { + beforeEach(() => { + return ctrl.filterByTag('test'); + }); + + it('should set tag filter', () => { + expect(ctrl.sections.length).toEqual(1); + expect(ctrl.query.tag[0]).toEqual('test'); + }); + + it('should have active filters', () => { + expect(ctrl.hasFilters).toBeTruthy(); + }); + + describe('when clearing filters', () => { + beforeEach(() => { + return ctrl.clearFilters(); + }); + + it('should reset tag filter', () => { + expect(ctrl.query.tag.length).toEqual(0); + }); + }); + }); + + describe('with starred filter', () => { + beforeEach(() => { + const yesOption: any = ctrl.starredFilterOptions[1]; + + ctrl.selectedStarredFilter = yesOption; + return ctrl.onStarredFilterChange(); + }); + + it('should set starred filter', () => { + expect(ctrl.sections.length).toEqual(1); + expect(ctrl.query.starred).toEqual(true); + }); + + it('should have active filters', () => { + expect(ctrl.hasFilters).toBeTruthy(); + }); + + describe('when clearing filters', () => { + beforeEach(() => { + return ctrl.clearFilters(); + }); + + it('should reset starred filter', () => { + expect(ctrl.query.starred).toEqual(false); + }); + }); + }); + }); + + describe('when selecting dashboards', () => { + let ctrl; + + beforeEach(() => { + ctrl = createCtrlWithStubs([]); + }); + + describe('and no dashboards are selected', () => { + beforeEach(() => { + ctrl.sections = [ + { + id: 1, + items: [ + { id: 2, checked: false } + ], + checked: false + }, + { + id: 0, + items: [ + { id: 3, checked: false } + ], + checked: false + } + ]; + ctrl.selectionChanged(); + }); + + it('should disable Move To button', () => { + expect(ctrl.canMove).toBeFalsy(); + }); + + it('should disable delete button', () => { + expect(ctrl.canDelete).toBeFalsy(); + }); + + describe('when select all is checked', () => { + beforeEach(() => { + ctrl.selectAllChecked = true; + ctrl.onSelectAllChanged(); + }); + + it('should select all folders and dashboards', () => { + expect(ctrl.sections[0].checked).toBeTruthy(); + expect(ctrl.sections[0].items[0].checked).toBeTruthy(); + expect(ctrl.sections[1].checked).toBeTruthy(); + expect(ctrl.sections[1].items[0].checked).toBeTruthy(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + }); + }); + + describe('and all folders and dashboards are selected', () => { + beforeEach(() => { + ctrl.sections = [ + { + id: 1, + items: [ + { id: 2, checked: true } + ], + checked: true + }, + { + id: 0, + items: [ + { id: 3, checked: true } + ], + checked: true + } + ]; + ctrl.selectionChanged(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + + describe('when select all is unchecked', () => { + beforeEach(() => { + ctrl.selectAllChecked = false; + ctrl.onSelectAllChanged(); + }); + + it('should uncheck all checked folders and dashboards', () => { + expect(ctrl.sections[0].checked).toBeFalsy(); + expect(ctrl.sections[0].items[0].checked).toBeFalsy(); + expect(ctrl.sections[1].checked).toBeFalsy(); + expect(ctrl.sections[1].items[0].checked).toBeFalsy(); + }); + + it('should disable Move To button', () => { + expect(ctrl.canMove).toBeFalsy(); + }); + + it('should disable delete button', () => { + expect(ctrl.canDelete).toBeFalsy(); + }); + }); + }); + + describe('and one dashboard in root is selected', () => { + beforeEach(() => { + ctrl.sections = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, checked: false } + ], + checked: false + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, checked: true } + ], + checked: false + } + ]; + ctrl.selectionChanged(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + }); + + describe('and one child dashboard is selected', () => { + beforeEach(() => { + ctrl.sections = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, checked: true } + ], + checked: false + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, checked: false } + ], + checked: false + } + ]; + + ctrl.selectionChanged(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + }); + + describe('and one child dashboard and one dashboard is selected', () => { + beforeEach(() => { + ctrl.sections = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, checked: true } + ], + checked: false + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, checked: true } + ], + checked: false + } + ]; + + ctrl.selectionChanged(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + }); + + describe('and one child dashboard and one folder is selected', () => { + beforeEach(() => { + ctrl.sections = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, checked: false } + ], + checked: true + }, + { + id: 3, + title: 'folder', + items: [ + { id: 4, checked: true } + ], + checked: false + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, checked: false } + ], + checked: false + } + ]; + + ctrl.selectionChanged(); + }); + + it('should enable Move To button', () => { + expect(ctrl.canMove).toBeTruthy(); + }); + + it('should enable delete button', () => { + expect(ctrl.canDelete).toBeTruthy(); + }); + }); + }); + + describe('when deleting dashboards', () => { + let toBeDeleted: any; + + beforeEach(() => { + ctrl = createCtrlWithStubs([]); + + ctrl.sections = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, checked: true, slug: 'folder-dash' } + ], + checked: true, + slug: 'folder' + }, + { + id: 3, + title: 'folder-2', + items: [ + { id: 3, checked: true, slug: 'folder-2-dash' } + ], + checked: false, + slug: 'folder-2' + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, checked: true, slug: 'root-dash' } + ], + checked: true + } + ]; + + toBeDeleted = ctrl.getFoldersAndDashboardsToDelete(); + }); + + it('should return 1 folder', () => { + expect(toBeDeleted.folders.length).toEqual(1); + }); + + it('should return 2 dashboards', () => { + expect(toBeDeleted.dashboards.length).toEqual(2); + }); + + it('should filter out children if parent is checked', () => { + expect(toBeDeleted.folders[0]).toEqual('folder'); + }); + + it('should not filter out children if parent not is checked', () => { + expect(toBeDeleted.dashboards[0]).toEqual('folder-2-dash'); + }); + + it('should not filter out children if parent is checked and root', () => { + expect(toBeDeleted.dashboards[1]).toEqual('root-dash'); + }); + }); + + describe('when moving dashboards', () => { + beforeEach(() => { + ctrl = createCtrlWithStubs([]); + + ctrl.sections = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, checked: true, slug: 'dash' } + ], + checked: false, + slug: 'folder' + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, checked: true, slug: 'dash-2' } + ], + checked: false + } + ]; + }); + + it('should get selected dashboards', () => { + const toBeMove = ctrl.getDashboardsToMove(); + expect(toBeMove.length).toEqual(2); + expect(toBeMove[0]).toEqual('dash'); + expect(toBeMove[1]).toEqual('dash-2'); + }); + }); +}); + +function createCtrlWithStubs(searchResponse: any, tags?: any) { + const searchSrvStub = { + search: (options: any) => { + return q.resolve(searchResponse); + }, + getDashboardTags: () => { + return q.resolve(tags || []); + } + }; + + return new ManageDashboardsCtrl({}, { getNav: () => { } }, searchSrvStub); +} diff --git a/public/app/core/specs/org_switcher.jest.ts b/public/app/core/specs/org_switcher.jest.ts new file mode 100644 index 00000000000..9597d78f586 --- /dev/null +++ b/public/app/core/specs/org_switcher.jest.ts @@ -0,0 +1,38 @@ +import {OrgSwitchCtrl} from '../components/org_switcher'; +import q from 'q'; + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + user: {orgId: 1} + } +})); + +describe('OrgSwitcher', () => { + describe('when switching org', () => { + let expectedHref; + let expectedUsingUrl; + + beforeEach(() => { + + const backendSrvStub: any = { + get: (url) => { return q.resolve([]); }, + post: (url) => { expectedUsingUrl = url; return q.resolve({}); } + }; + + const orgSwitcherCtrl = new OrgSwitchCtrl(backendSrvStub); + + orgSwitcherCtrl.getWindowLocationHref = () => 'http://localhost:3000?orgId=1&from=now-3h&to=now'; + orgSwitcherCtrl.setWindowLocationHref = (href) => expectedHref = href; + + return orgSwitcherCtrl.setUsingOrg({orgId: 2}); + }); + + it('should switch orgId in call to backend', () => { + expect(expectedUsingUrl).toBe('/api/user/using/2'); + }); + + it('should switch orgId in url', () => { + expect(expectedHref).toBe('http://localhost:3000?orgId=2&from=now-3h&to=now'); + }); + }); +}); diff --git a/public/app/core/specs/search.jest.ts b/public/app/core/specs/search.jest.ts new file mode 100644 index 00000000000..d3058e7550e --- /dev/null +++ b/public/app/core/specs/search.jest.ts @@ -0,0 +1,335 @@ +import { SearchCtrl } from '../components/search/search'; +import { SearchSrv } from '../services/search_srv'; + +describe('SearchCtrl', () => { + const searchSrvStub = { + search: (options: any) => {}, + getDashboardTags: () => {} + }; + let ctrl = new SearchCtrl({}, {}, {}, searchSrvStub, { onAppEvent: () => { } }); + + describe('Given an empty result', () => { + beforeEach(() => { + ctrl.results = []; + }); + + describe('When navigating down one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + }); + + it('should not navigate', () => { + expect(ctrl.selectedIndex).toBe(0); + }); + }); + + describe('When navigating up one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(-1); + }); + + it('should not navigate', () => { + expect(ctrl.selectedIndex).toBe(0); + }); + }); + }); + + describe('Given a result of one selected collapsed folder with no dashboards and a root folder with 2 dashboards', () => { + beforeEach(() => { + ctrl.results = [ + { + id: 1, + title: 'folder', + items: [], + selected: true, + expanded: false, + toggle: (i) => i.expanded = !i.expanded + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, selected: false }, + { id: 5, selected: false } + ], + selected: false, + expanded: true, + toggle: (i) => i.expanded = !i.expanded + } + ]; + }); + + describe('When navigating down one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + }); + + it('should select first dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeTruthy(); + expect(ctrl.results[1].items[1].selected).toBeFalsy(); + }); + }); + + describe('When navigating down two steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + ctrl.moveSelection(1); + }); + + it('should select last dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeFalsy(); + expect(ctrl.results[1].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating down three steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + ctrl.moveSelection(1); + ctrl.moveSelection(1); + }); + + it('should select first folder', () => { + expect(ctrl.results[0].selected).toBeTruthy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeFalsy(); + expect(ctrl.results[1].items[1].selected).toBeFalsy(); + }); + }); + + describe('When navigating up one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(-1); + }); + + it('should select last dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeFalsy(); + expect(ctrl.results[1].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating up two steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(-1); + ctrl.moveSelection(-1); + }); + + it('should select first dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeTruthy(); + expect(ctrl.results[1].items[1].selected).toBeFalsy(); + }); + }); + }); + + describe('Given a result of one selected collapsed folder with 2 dashboards and a root folder with 2 dashboards', () => { + beforeEach(() => { + ctrl.results = [ + { + id: 1, + title: 'folder', + items: [ + { id: 2, selected: false }, + { id: 4, selected: false } + ], + selected: true, + expanded: false, + toggle: (i) => i.expanded = !i.expanded + }, + { + id: 0, + title: 'Root', + items: [ + { id: 3, selected: false }, + { id: 5, selected: false } + ], + selected: false, + expanded: true, + toggle: (i) => i.expanded = !i.expanded + } + ]; + }); + + describe('When navigating down one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + }); + + it('should select first dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeTruthy(); + expect(ctrl.results[1].items[1].selected).toBeFalsy(); + }); + }); + + describe('When navigating down two steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + ctrl.moveSelection(1); + }); + + it('should select last dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeFalsy(); + expect(ctrl.results[1].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating down three steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(1); + ctrl.moveSelection(1); + ctrl.moveSelection(1); + }); + + it('should select first folder', () => { + expect(ctrl.results[0].selected).toBeTruthy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeFalsy(); + expect(ctrl.results[1].items[1].selected).toBeFalsy(); + }); + }); + + describe('When navigating up one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(-1); + }); + + it('should select last dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeFalsy(); + expect(ctrl.results[1].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating up two steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 0; + ctrl.moveSelection(-1); + ctrl.moveSelection(-1); + }); + + it('should select first dashboard in root folder', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[1].selected).toBeFalsy(); + expect(ctrl.results[1].items[0].selected).toBeTruthy(); + expect(ctrl.results[1].items[1].selected).toBeFalsy(); + }); + }); + }); + + describe('Given a result of a search with 2 dashboards where the first is selected', () => { + beforeEach(() => { + ctrl.results = [ + { + hideHeader: true, + items: [ + { id: 3, selected: true }, + { id: 5, selected: false } + ], + selected: false, + expanded: true, + toggle: (i) => i.expanded = !i.expanded + } + ]; + }); + + describe('When navigating down one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 1; + ctrl.moveSelection(1); + }); + + it('should select last dashboard', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating down two steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 1; + ctrl.moveSelection(1); + ctrl.moveSelection(1); + }); + + it('should select first dashboard', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeTruthy(); + expect(ctrl.results[0].items[1].selected).toBeFalsy(); + }); + }); + + describe('When navigating down three steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 1; + ctrl.moveSelection(1); + ctrl.moveSelection(1); + ctrl.moveSelection(1); + }); + + it('should select last dashboard', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating up one step', () => { + beforeEach(() => { + ctrl.selectedIndex = 1; + ctrl.moveSelection(-1); + }); + + it('should select last dashboard', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[1].selected).toBeTruthy(); + }); + }); + + describe('When navigating up two steps', () => { + beforeEach(() => { + ctrl.selectedIndex = 1; + ctrl.moveSelection(-1); + ctrl.moveSelection(-1); + }); + + it('should select first dashboard', () => { + expect(ctrl.results[0].selected).toBeFalsy(); + expect(ctrl.results[0].items[0].selected).toBeTruthy(); + expect(ctrl.results[0].items[1].selected).toBeFalsy(); + }); + }); + }); +}); diff --git a/public/app/core/specs/search_results.jest.ts b/public/app/core/specs/search_results.jest.ts new file mode 100644 index 00000000000..7084f5e7d8f --- /dev/null +++ b/public/app/core/specs/search_results.jest.ts @@ -0,0 +1,97 @@ +import { SearchResultsCtrl } from '../components/search/search_results'; + +describe('SearchResultsCtrl', () => { + let ctrl; + + describe('when checking an item that is not checked', () => { + let item = {checked: false}; + let selectionChanged = false; + + beforeEach(() => { + ctrl = new SearchResultsCtrl({}); + ctrl.onSelectionChanged = () => selectionChanged = true; + ctrl.toggleSelection(item); + }); + + it('should set checked to true', () => { + expect(item.checked).toBeTruthy(); + }); + + it('should trigger selection changed callback', () => { + expect(selectionChanged).toBeTruthy(); + }); + }); + + describe('when checking an item that is checked', () => { + let item = {checked: true}; + let selectionChanged = false; + + beforeEach(() => { + ctrl = new SearchResultsCtrl({}); + ctrl.onSelectionChanged = () => selectionChanged = true; + ctrl.toggleSelection(item); + }); + + it('should set checked to false', () => { + expect(item.checked).toBeFalsy(); + }); + + it('should trigger selection changed callback', () => { + expect(selectionChanged).toBeTruthy(); + }); + }); + + describe('when selecting a tag', () => { + let selectedTag = null; + + beforeEach(() => { + ctrl = new SearchResultsCtrl({}); + ctrl.onTagSelected = (tag) => selectedTag = tag; + ctrl.selectTag('tag-test'); + }); + + it('should trigger tag selected callback', () => { + expect(selectedTag["$tag"]).toBe('tag-test'); + }); + }); + + describe('when toggle a collapsed folder', () => { + let folderExpanded = false; + + beforeEach(() => { + ctrl = new SearchResultsCtrl({}); + ctrl.onFolderExpanding = () => { folderExpanded = true; }; + + let folder = { + expanded: false, + toggle: () => Promise.resolve(folder) + }; + + ctrl.toggleFolderExpand(folder); + }); + + it('should trigger folder expanding callback', () => { + expect(folderExpanded).toBeTruthy(); + }); + }); + + describe('when toggle an expanded folder', () => { + let folderExpanded = false; + + beforeEach(() => { + ctrl = new SearchResultsCtrl({}); + ctrl.onFolderExpanding = () => { folderExpanded = true; }; + + let folder = { + expanded: true, + toggle: () => Promise.resolve(folder) + }; + + ctrl.toggleFolderExpand(folder); + }); + + it('should not trigger folder expanding callback', () => { + expect(folderExpanded).toBeFalsy(); + }); + }); +}); diff --git a/public/app/core/specs/search_srv.jest.ts b/public/app/core/specs/search_srv.jest.ts new file mode 100644 index 00000000000..f3135aa5f58 --- /dev/null +++ b/public/app/core/specs/search_srv.jest.ts @@ -0,0 +1,286 @@ +import { SearchSrv } from 'app/core/services/search_srv'; +import { BackendSrvMock } from 'test/mocks/backend_srv'; +import impressionSrv from 'app/core/services/impression_srv'; +import { contextSrv } from 'app/core/services/context_srv'; +import { beforeEach } from 'test/lib/common'; + +jest.mock('app/core/store', () => { + return { + getBool: jest.fn(), + set: jest.fn(), + }; +}); + +jest.mock('app/core/services/impression_srv', () => { + return { + getDashboardOpened: jest.fn, + }; +}); + +describe('SearchSrv', () => { + let searchSrv, backendSrvMock; + + beforeEach(() => { + backendSrvMock = new BackendSrvMock(); + searchSrv = new SearchSrv(backendSrvMock, Promise); + + contextSrv.isSignedIn = true; + impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([]); + }); + + describe('With recent dashboards', () => { + let results; + + beforeEach(() => { + backendSrvMock.search = jest + .fn() + .mockReturnValueOnce( + Promise.resolve([{ id: 2, title: 'second but first' }, { id: 1, title: 'first but second' }]), + ) + .mockReturnValue(Promise.resolve([])); + + impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([1, 2]); + + return searchSrv.search({ query: '' }).then(res => { + results = res; + }); + }); + + it('should include recent dashboards section', () => { + expect(results[0].title).toBe('Recent Boards'); + }); + + it('should return order decided by impressions store not api', () => { + expect(results[0].items[0].title).toBe('first but second'); + expect(results[0].items[1].title).toBe('second but first'); + }); + + describe('and 3 recent dashboards removed in backend', () => { + let results; + + beforeEach(() => { + backendSrvMock.search = jest + .fn() + .mockReturnValueOnce( + Promise.resolve([{ id: 2, title: 'two' }, { id: 1, title: 'one' }]), + ) + .mockReturnValue(Promise.resolve([])); + + impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([4, 5, 1, 2, 3]); + + return searchSrv.search({ query: '' }).then(res => { + results = res; + }); + }); + + it('should return 2 dashboards', () => { + expect(results[0].items.length).toBe(2); + expect(results[0].items[0].id).toBe(1); + expect(results[0].items[1].id).toBe(2); + }); + }); + }); + + describe('With starred dashboards', () => { + let results; + + beforeEach(() => { + backendSrvMock.search = jest + .fn() + .mockReturnValue(Promise.resolve([ + {id: 1, title: 'starred'} + ])); + + return searchSrv.search({ query: '' }).then(res => { + results = res; + }); + }); + + it('should include starred dashboards section', () => { + expect(results[0].title).toBe('Starred Boards'); + expect(results[0].items.length).toBe(1); + }); + }); + + describe('With starred dashboards and recent', () => { + let results; + + beforeEach(() => { + backendSrvMock.search = jest + .fn() + .mockReturnValueOnce(Promise.resolve([ + {id: 1, title: 'starred and recent', isStarred: true}, + {id: 2, title: 'recent'} + ])) + .mockReturnValue(Promise.resolve([ + {id: 1, title: 'starred and recent'} + ])); + + impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([1,2]); + return searchSrv.search({ query: '' }).then(res => { + results = res; + }); + }); + + it('should not show starred in recent', () => { + expect(results[1].title).toBe('Recent Boards'); + expect(results[1].items[0].title).toBe('recent'); + }); + + it('should show starred', () => { + expect(results[0].title).toBe('Starred Boards'); + expect(results[0].items[0].title).toBe('starred and recent'); + }); + + }); + + describe('with no query string and dashboards with folders returned', () => { + let results; + + beforeEach(() => { + backendSrvMock.search = jest + .fn() + .mockReturnValueOnce(Promise.resolve([])) + .mockReturnValue( + Promise.resolve([ + { + title: 'folder1', + type: 'dash-folder', + id: 1, + }, + { + title: 'dash with no folder', + type: 'dash-db', + id: 2, + }, + { + title: 'dash in folder1 1', + type: 'dash-db', + id: 3, + folderId: 1, + }, + { + title: 'dash in folder1 2', + type: 'dash-db', + id: 4, + folderId: 1, + }, + ]), + ); + + return searchSrv.search({ query: '' }).then(res => { + results = res; + }); + }); + + it('should create sections for each folder and root', () => { + expect(results).toHaveLength(2); + }); + + it('should place folders first', () => { + expect(results[0].title).toBe('folder1'); + }); + }); + + describe('with query string and dashboards with folders returned', () => { + let results; + + beforeEach(() => { + backendSrvMock.search = jest.fn(); + + backendSrvMock.search.mockReturnValue( + Promise.resolve([ + { + id: 2, + title: 'dash with no folder', + type: 'dash-db', + }, + { + id: 3, + title: 'dash in folder1 1', + type: 'dash-db', + folderId: 1, + folderTitle: 'folder1', + }, + ]), + ); + + return searchSrv.search({ query: 'search' }).then(res => { + results = res; + }); + }); + + it('should not specify folder ids', () => { + expect(backendSrvMock.search.mock.calls[0][0].folderIds).toHaveLength(0); + }); + + it('should group results by folder', () => { + expect(results).toHaveLength(2); + }); + }); + + describe('with tags', () => { + beforeEach(() => { + backendSrvMock.search = jest.fn(); + backendSrvMock.search.mockReturnValue(Promise.resolve([])); + + return searchSrv.search({ tag: ['atag'] }).then(() => {}); + }); + + it('should send tags query to backend search', () => { + expect(backendSrvMock.search.mock.calls[0][0].tag).toHaveLength(1); + }); + }); + + describe('with starred', () => { + beforeEach(() => { + backendSrvMock.search = jest.fn(); + backendSrvMock.search.mockReturnValue(Promise.resolve([])); + + return searchSrv.search({ starred: true }).then(() => {}); + }); + + it('should send starred query to backend search', () => { + expect(backendSrvMock.search.mock.calls[0][0].starred).toEqual(true); + }); + }); + + describe('when skipping recent dashboards', () => { + let getRecentDashboardsCalled = false; + + beforeEach(() => { + backendSrvMock.search = jest.fn(); + backendSrvMock.search.mockReturnValue(Promise.resolve([])); + + searchSrv.getRecentDashboards = () => { + getRecentDashboardsCalled = true; + }; + + return searchSrv.search({ skipRecent: true }).then(() => {}); + }); + + it('should not fetch recent dashboards', () => { + expect(getRecentDashboardsCalled).toBeFalsy(); + }); + }); + + describe('when skipping starred dashboards', () => { + let getStarredCalled = false; + + beforeEach(() => { + backendSrvMock.search = jest.fn(); + backendSrvMock.search.mockReturnValue(Promise.resolve([])); + impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([]); + + searchSrv.getStarred = () => { + getStarredCalled = true; + }; + + return searchSrv.search({ skipStarred: true }).then(() => {}); + }); + + it('should not fetch starred dashboards', () => { + expect(getStarredCalled).toBeFalsy(); + }); + }); +}); diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index 0f3dcbc1171..5a242a5abc1 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -1,4 +1,5 @@ import kbn from 'app/core/utils/kbn'; +import {getFlotTickDecimals} from 'app/core/utils/ticks'; import _ from 'lodash'; function matchSeriesOverride(aliasOrRegex, seriesAlias) { @@ -16,6 +17,48 @@ function translateFillOption(fill) { return fill === 0 ? 0.001 : fill/10; } +/** + * Calculate decimals for legend and update values for each series. + * @param data series data + * @param panel + */ +export function updateLegendValues(data: TimeSeries[], panel) { + for (let i = 0; i < data.length; i++) { + let series = data[i]; + let yaxes = panel.yaxes; + let axis = yaxes[series.yaxis - 1]; + let {tickDecimals, scaledDecimals} = getFlotTickDecimals(data, axis); + let formater = kbn.valueFormats[panel.yaxes[series.yaxis - 1].format]; + + // decimal override + if (_.isNumber(panel.decimals)) { + series.updateLegendValues(formater, panel.decimals, null); + } else { + // auto decimals + // legend and tooltip gets one more decimal precision + // than graph legend ticks + tickDecimals = (tickDecimals || -1) + 1; + series.updateLegendValues(formater, tickDecimals, scaledDecimals + 2); + } + } +} + +export function getDataMinMax(data: TimeSeries[]) { + let datamin = null; + let datamax = null; + + for (let series of data) { + if (datamax === null || datamax < series.stats.max) { + datamax = series.stats.max; + } + if (datamin === null || datamin > series.stats.min) { + datamin = series.stats.min; + } + } + + return {datamin, datamax}; +} + export default class TimeSeries { datapoints: any; id: string; diff --git a/public/app/core/utils/react2angular.ts b/public/app/core/utils/react2angular.ts index ad6f7476d6a..e7ad3502f88 100644 --- a/public/app/core/utils/react2angular.ts +++ b/public/app/core/utils/react2angular.ts @@ -1,10 +1,7 @@ import coreModule from 'app/core/core_module'; export function react2AngularDirective(name: string, component: any, options: any) { - coreModule.directive(name, ['reactDirective', reactDirective => { return reactDirective(component, options); }]); - } - diff --git a/public/app/core/utils/ticks.ts b/public/app/core/utils/ticks.ts index b033e9247a1..8b16b528bdd 100644 --- a/public/app/core/utils/ticks.ts +++ b/public/app/core/utils/ticks.ts @@ -1,3 +1,5 @@ +import {getDataMinMax} from 'app/core/time_series2'; + /** * Calculate tick step. * Implementation from d3-array (ticks.js) @@ -32,6 +34,7 @@ export function getScaledDecimals(decimals, tick_size) { /** * Calculate tick size based on min and max values, number of ticks and precision. + * Implementation from Flot. * @param min Axis minimum * @param max Axis maximum * @param noTicks Number of ticks @@ -65,3 +68,91 @@ export function getFlotTickSize(min: number, max: number, noTicks: number, tickD return size; } + +/** + * Calculate axis range (min and max). + * Implementation from Flot. + */ +export function getFlotRange(panelMin, panelMax, datamin, datamax) { + const autoscaleMargin = 0.02; + + let min = +(panelMin != null ? panelMin : datamin); + let max = +(panelMax != null ? panelMax : datamax); + let delta = max - min; + + if (delta === 0.0) { + // Grafana fix: wide Y min and max using increased wideFactor + // when all series values are the same + var wideFactor = 0.25; + var widen = Math.abs(max === 0 ? 1 : max * wideFactor); + + if (panelMin === null) { + min -= widen; + } + // always widen max if we couldn't widen min to ensure we + // don't fall into min == max which doesn't work + if (panelMax == null || panelMin != null) { + max += widen; + } + } else { + // consider autoscaling + var margin = autoscaleMargin; + if (margin != null) { + if (panelMin == null) { + min -= delta * margin; + // make sure we don't go below zero if all values + // are positive + if (min < 0 && datamin != null && datamin >= 0) { + min = 0; + } + } + if (panelMax == null) { + max += delta * margin; + if (max > 0 && datamax != null && datamax <= 0) { + max = 0; + } + } + } + } + return {min, max}; +} + +/** + * Calculate tick decimals. + * Implementation from Flot. + */ +export function getFlotTickDecimals(data, axis) { + let {datamin, datamax} = getDataMinMax(data); + let {min, max} = getFlotRange(axis.min, axis.max, datamin, datamax); + let noTicks = 3; + let tickDecimals, maxDec; + let delta = (max - min) / noTicks; + let dec = -Math.floor(Math.log(delta) / Math.LN10); + + let magn = Math.pow(10, -dec); + // norm is between 1.0 and 10.0 + let norm = delta / magn; + let size; + + if (norm < 1.5) { + size = 1; + } else if (norm < 3) { + size = 2; + // special case for 2.5, requires an extra decimal + if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { + size = 2.5; + ++dec; + } + } else if (norm < 7.5) { + size = 5; + } else { + size = 10; + } + + size *= magn; + + tickDecimals = Math.max(0, maxDec != null ? maxDec : dec); + // grafana addition + const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10); + return {tickDecimals, scaledDecimals}; +} diff --git a/public/app/features/admin/admin.ts b/public/app/features/admin/admin.ts index 5f640754aef..6683ad6e85b 100644 --- a/public/app/features/admin/admin.ts +++ b/public/app/features/admin/admin.ts @@ -10,7 +10,7 @@ class AdminSettingsCtrl { /** @ngInject **/ constructor($scope, backendSrv, navModelSrv) { - this.navModel = navModelSrv.getAdminNav(); + this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-settings', 1); backendSrv.get('/api/admin/settings').then(function(settings) { $scope.settings = settings; @@ -24,7 +24,7 @@ class AdminHomeCtrl { /** @ngInject **/ constructor(navModelSrv) { - this.navModel = navModelSrv.getAdminNav(); + this.navModel = navModelSrv.getNav('cfg', 'admin', 1); } } @@ -34,7 +34,7 @@ export class AdminStatsCtrl { /** @ngInject */ constructor(backendSrv: any, navModelSrv) { - this.navModel = navModelSrv.getAdminNav(); + this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-stats', 1); backendSrv.get('/api/admin/stats').then(stats => { this.stats = stats; diff --git a/public/app/features/admin/admin_edit_org_ctrl.ts b/public/app/features/admin/admin_edit_org_ctrl.ts index d7e8a5109d5..2c69c00ef9d 100644 --- a/public/app/features/admin/admin_edit_org_ctrl.ts +++ b/public/app/features/admin/admin_edit_org_ctrl.ts @@ -5,7 +5,7 @@ export class AdminEditOrgCtrl { /** @ngInject */ constructor($scope, $routeParams, backendSrv, $location, navModelSrv) { $scope.init = function() { - $scope.navModel = navModelSrv.getAdminNav(); + $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1); if ($routeParams.id) { $scope.getOrg($routeParams.id); diff --git a/public/app/features/admin/admin_edit_user_ctrl.ts b/public/app/features/admin/admin_edit_user_ctrl.ts index 0e56584aed1..1ed885505c3 100644 --- a/public/app/features/admin/admin_edit_user_ctrl.ts +++ b/public/app/features/admin/admin_edit_user_ctrl.ts @@ -8,7 +8,7 @@ export class AdminEditUserCtrl { $scope.user = {}; $scope.newOrg = { name: '', role: 'Editor' }; $scope.permissions = {}; - $scope.navModel = navModelSrv.getAdminNav(); + $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-users', 1); $scope.init = function() { if ($routeParams.id) { diff --git a/public/app/features/admin/admin_list_orgs_ctrl.ts b/public/app/features/admin/admin_list_orgs_ctrl.ts index 34d1eca0f2f..e8722f7e19b 100644 --- a/public/app/features/admin/admin_list_orgs_ctrl.ts +++ b/public/app/features/admin/admin_list_orgs_ctrl.ts @@ -5,7 +5,7 @@ export class AdminListOrgsCtrl { /** @ngInject */ constructor($scope, backendSrv, navModelSrv) { $scope.init = function() { - $scope.navModel = navModelSrv.getAdminNav(); + $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1); $scope.getOrgs(); }; diff --git a/public/app/features/admin/admin_list_users_ctrl.ts b/public/app/features/admin/admin_list_users_ctrl.ts index 2fb2d9a0e84..1574bd07a77 100644 --- a/public/app/features/admin/admin_list_users_ctrl.ts +++ b/public/app/features/admin/admin_list_users_ctrl.ts @@ -1,5 +1,3 @@ -/// - export default class AdminListUsersCtrl { users: any; pages = []; @@ -12,7 +10,7 @@ export default class AdminListUsersCtrl { /** @ngInject */ constructor(private $scope, private backendSrv, navModelSrv) { - this.navModel = navModelSrv.getAdminNav(); + this.navModel = navModelSrv.getNav('cfg', 'admin', 'global-users', 1); this.query = ''; this.getUsers(); } diff --git a/public/app/features/admin/partials/admin_home.html b/public/app/features/admin/partials/admin_home.html index 4409f518f0b..ea61385f006 100644 --- a/public/app/features/admin/partials/admin_home.html +++ b/public/app/features/admin/partials/admin_home.html @@ -1,31 +1,11 @@ - + -
    - +
    - - Manage Users - - - - Manage Organizations - - - - View Server Settings - - - - View Server Stats - - - - Style guide - +
    + Grafana is a multi-tenant system where most can be configured per organization. These + admin pages are for server admins where you can manage orgs, & all users across all orgs. +
    diff --git a/public/app/features/admin/partials/edit_org.html b/public/app/features/admin/partials/edit_org.html index 39028645b38..975d663e9b0 100644 --- a/public/app/features/admin/partials/edit_org.html +++ b/public/app/features/admin/partials/edit_org.html @@ -1,9 +1,7 @@ - + -
    - +
    +

    Edit Organization

    @@ -18,7 +16,7 @@

    Organization Users

    - +
    @@ -29,8 +27,12 @@
    Username Email{{orgUser.login}} {{orgUser.email}} - +
    + + + +
    diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html index 384601affb7..5b0efa8bdf3 100644 --- a/public/app/features/admin/partials/edit_user.html +++ b/public/app/features/admin/partials/edit_user.html @@ -1,8 +1,9 @@ - + -
    -