diff --git a/CHANGELOG.md b/CHANGELOG.md index d7dd13dba98..6508c4ff76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ -# 5.0.0 (unreleased / master branch) +# 5.0.0-beta2 (unrelased) -Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5. +# 5.0.0-beta1 (2018-02-05) + +Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=Izr0IBgoTZQ) of Grafana v5. ### New Major Features - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) @@ -9,6 +11,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha - **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) +- **Persistent dashboard url's**: New url's for dashboards that allows renaming dashboards without breaking links. [#7883](https://github.com/grafana/grafana/issues/7883) ## Breaking changes @@ -18,6 +21,9 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when * **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) +* **HTTP API** + - `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url). + ## New Dashboard Grid The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. @@ -58,10 +64,22 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: * **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) * **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +* **Cloudwatch**: Fix for multi-valued templated queries. [#9903](https://github.com/grafana/grafana/issues/9903) ## Tech * **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) +## Deprecation notes + +### HTTP API +The following operations have been deprecated and will be removed in a future release: + - `GET /api/dashboards/db/:slug` -> Use `GET /api/dashboards/uid/:uid` instead + - `DELETE /api/dashboards/db/:slug` -> Use `DELETE /api/dashboards/uid/:uid` instead + +The following properties have been deprecated and will be removed in a future release: + - `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead + - `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead + # 4.6.3 (2017-12-14) ## Fixes diff --git a/README.md b/README.md index 80a80b694c1..81fb1f8d42b 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,11 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = ### Running tests -- You can run backend Golang tests using "go test ./pkg/...". -- Execute all frontend tests with "npm run test" +#### Frontend +Execute all frontend tests +```bash +npm run test +``` Writing & watching frontend tests (we have two test runners) @@ -92,6 +95,18 @@ Writing & watching frontend tests (we have two test runners) - Start watcher: `npm run karma` - Karma+Mocha runs all files that end with the name "_specs.ts". +#### Backend +```bash +# Run Golang tests using sqlite3 as database (default) +go test ./pkg/... + +# Run Golang tests using mysql as database - convenient to use /docker/blocks/mysql_tests +GRAFANA_TEST_DB=mysql go test ./pkg/... + +# Run Golang tests using postgres as database - convenient to use /docker/blocks/postgres_tests +GRAFANA_TEST_DB=postgres go test ./pkg/... +``` + ## Contribute If you have any idea for an improvement or found a bug, do not hesitate to open an issue. diff --git a/conf/provisioning/dashboards/sample.yaml b/conf/provisioning/dashboards/sample.yaml index 40992d1461e..f0dcca9b47a 100644 --- a/conf/provisioning/dashboards/sample.yaml +++ b/conf/provisioning/dashboards/sample.yaml @@ -3,4 +3,4 @@ # folder: '' # type: file # options: -# folder: /var/lib/grafana/dashboards \ No newline at end of file +# path: /var/lib/grafana/dashboards diff --git a/docs/sources/administration/permissions.md b/docs/sources/administration/permissions.md index 5cfc6ecac9c..5796d50f1fe 100644 --- a/docs/sources/administration/permissions.md +++ b/docs/sources/administration/permissions.md @@ -28,7 +28,7 @@ in that organization. Can do everything scoped to the organization. For example: -- Add & Edit data data sources. +- Add & Edit data sources. - Add & Edit organization users & teams. - Configure App plugins & set org settings. @@ -73,4 +73,13 @@ The highest permission always wins so if you for example want to hide a folder o Access Control List (ACL). - You cannot override permissions for users with **Org Admin Role** -- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule. \ No newline at end of file +- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule. + +### Data source permissions + +Permissions on dashboards and folders **do not** include permissions on data sources. A user with `Viewer` role +can still issue any possible query to a data source, not just those queries that exist on dashboards he/she has access to. +We hope to add permissions on data sources in a future release. Until then **do not** view dashboard permissions as a secure +way to restrict user data access. Dashboard permissions only limits what dashboards & folders a user can view & edit not which +data sources a user can access nor what queries a user can issue. + diff --git a/docs/sources/guides/whats-new-in-v5.md b/docs/sources/guides/whats-new-in-v5.md index 3f5b3626773..bd960ed1694 100644 --- a/docs/sources/guides/whats-new-in-v5.md +++ b/docs/sources/guides/whats-new-in-v5.md @@ -12,6 +12,8 @@ weight = -6 # What's New in Grafana v5.0 +> Out in beta: [Download now!](https://grafana.com/grafana/download/5.0.0-beta1) + This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements. - [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts. @@ -22,10 +24,12 @@ This is the most substantial update that Grafana has ever seen. This article wil - [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system. - [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files. - [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files. +- [Persistent dashboard url's]({{< relref "#dashboard-model-persistent-url-s-and-api-changes" >}}) makes it possible to rename dashboards without breaking links. +- [Graphite Tags & Integrated Function Docs]({{< relref "#graphite-tags-integrated-function-docs" >}}). ### Video showing new features - +
## New Dashboard Layout Engine @@ -36,7 +40,7 @@ The new dashboard layout engine allows for much easier movement and sizing of pa a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the -new position system and look close to identical. The new panel position makes dashboards saved in v5.0 not compatible +new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible with older versions of Grafana.
@@ -49,7 +53,7 @@ Almost every page has seen significant UX improvements. All pages (except dashbo
-### Dashboard Settings +## Dashboard Settings {{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}} Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard @@ -61,7 +65,7 @@ settings views have been combined with a side nav which allows you to easily mov {{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}} -This theme has not seen a lot of love in recent years and we felt it was time to rework it and give it a major overhaul. We are very happy with the result. +This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result.
@@ -78,22 +82,26 @@ which is very useful if you have a lot of dashboards or multiple teams. ## Teams -A team is a new concept in Grafana v5. They are simply a group of users that can be then be used in the new permission system for dashboards and folders. Only an admin can create teams. +A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams. We hope to do more with teams in future releases like integration with LDAP and a team landing page. ## Permissions {{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}} -You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and replaced with specific teams or users enabling more control over what a user can see and edit. +You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and +replaced with specific teams or users enabling more control over what a user can see and edit. + +Dashboard permissions only limits what dashboards & folders a user can view & edit not which +data sources a user can access nor what queries a user can issue.
-# Provisioning from configuration +## Provisioning from configuration In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards. But that required the service to be running before you started creating dashboards and you also needed to -set up credentials for the HTTP API. In 5.0 we decided to improve this experience by adding a new active +set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs and alerts as well. @@ -111,10 +119,36 @@ in sync with dashboards in Grafana's database. The dashboard provisioner has mul which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards) -# Dashboard model & API -We are introducing a new identifier (`uid`) in the dashboard JSON model. The new identifier will be a 9-12 character long unique id. -We are also changing the route for getting dashboards to use this `uid` instead of the slug that the current route and API are using. -We will keep supporting the old route for backward compatibility. This will make it possible to change the title on dashboards without breaking links. -Sharing dashboards between instances becomes much easier since the uid is unique (unique enough). This might seem like a small change, -but we are incredibly excited about it since it will make it much easier to manage, collaborate and navigate between dashboards. +## Graphite Tags & Integrated Function Docs + +{{< docs-imagebox img="/img/docs/v50/graphite_tags.png" max-width="1000px" class="docs-image--right" >}} + +The Graphite query editor has been updated to support the latest Graphite version (v1.2) that adds +many new functions and support for querying by tags. You can now also view function documentation right in the query editor! + +Read more on [Graphite Tag Support](http://graphite.readthedocs.io/en/latest/tags.html?highlight=tags). + +
+ +## Dashboard model, persistent url's and API changes + +We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically +generated if not provided when creating a dashboard and will have a length of 9-12 characters. + +The unique identifier allows having persistent URL's for accessing dashboards, sharing them +between instances and when using [dashboard provisioning](#dashboards). This means that dashboard can +be renamed without breaking any links. We're changing the url format for dashboards +from `/dashboard/db/:slug` to `/d/:uid/:slug`. We'll keep supporting the old slug-based url's for dashboards +and redirects to the new one for backward compatibility. Please note that the old slug-based url's +have been deprecated and will be removed in a future release. + +Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough). +This might seem like a small change, but we are incredibly excited about it since it will make it +much easier to manage, collaborate and navigate between dashboards. + +### API changes +New uid-based routes in the dashboard API have been introduced to retrieve and delete dashboards. +The corresponding slug-based routes have been deprecated and will be removed in a future release. + + diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index 221552414e9..3860ae490b1 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -62,7 +62,7 @@ Content-Type: application/json } "newStateDate": "2016-12-25", "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "url": "http://grafana.com/dashboard/db/sensors" } ] ``` @@ -94,7 +94,7 @@ Content-Type: application/json "state": "alerting", "newStateDate": "2016-12-25", "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "url": "http://grafana.com/dashboard/db/sensors" } ``` @@ -196,7 +196,7 @@ Content-Type: application/json ## Create alert notification -You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. +You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. `POST /api/alert-notifications` @@ -294,4 +294,4 @@ Content-Type: application/json { "message": "Notification deleted" } -``` \ No newline at end of file +``` diff --git a/docs/sources/http_api/index.md b/docs/sources/http_api/index.md index 9c5d0e7d49a..cbfe004b14c 100644 --- a/docs/sources/http_api/index.md +++ b/docs/sources/http_api/index.md @@ -18,12 +18,15 @@ dashboards, creating users and updating data sources. ## Supported HTTP APIs: -* [Authentication API]({{< relref "auth.md" >}}) -* [Dashboard API]({{< relref "dashboard.md" >}}) -* [Data Source API]({{< relref "data_source.md" >}}) -* [Organisation API]({{< relref "org.md" >}}) -* [User API]({{< relref "user.md" >}}) -* [Admin API]({{< relref "admin.md" >}}) -* [Snapshot API]({{< relref "snapshot.md" >}}) -* [Preferences API]({{< relref "preferences.md" >}}) -* [Other API]({{< relref "other.md" >}}) +* [Authentication API]({{< relref "/http_api/auth.md" >}}) +* [Dashboard API]({{< relref "/http_api/dashboard.md" >}}) +* [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}}) +* [Data Source API]({{< relref "http_api/data_source.md" >}}) +* [Organisation API]({{< relref "http_api/org.md" >}}) +* [Snapshot API]({{< relref "http_api/snapshot.md" >}}) +* [Annotations API]({{< relref "http_api/annotations.md" >}}) +* [Alerting API]({{< relref "http_api/alerting.md" >}}) +* [User API]({{< relref "http_api/user.md" >}}) +* [Admin API]({{< relref "http_api/admin.md" >}}) +* [Preferences API]({{< relref "http_api/preferences.md" >}}) +* [Other API]({{< relref "http_api/other.md" >}}) diff --git a/docs/sources/index.md b/docs/sources/index.md index c1072db47a5..3c59b9baba0 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -1,49 +1,107 @@ +++ -title = "Docs Home" -description = "Install guide for Grafana" +title = "Grafana documentation" +description = "Guides, Installation & Feature Documentation" keywords = ["grafana", "installation", "documentation"] type = "docs" aliases = ["v1.1", "guides/reference/admin"] +++ -# Welcome to the Grafana Documentation +# Grafana Documentation -Grafana is an open source metric analytics & visualization suite. It is most commonly used for -visualizing time series data for infrastructure and application analytics but many use it in -other domains including industrial sensors, home automation, weather, and process control. +

Installing Grafana

+ -## Installing Grafana -- [Installing on Debian / Ubuntu](installation/debian) -- [Installing on RPM-based Linux (CentOS, Fedora, OpenSuse, RedHat)](installation/rpm) -- [Installing on Mac OS X](installation/mac) -- [Installing on Windows](installation/windows) -- [Installing on Docker](installation/docker) -- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](administration/provisioning#configuration-management-tools) -- [Nightly Builds](https://grafana.com/grafana/download) +

Guides

-For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}}) -instructions for more information. + -## Configuring Grafana - -The back-end web server has a number of configuration options. Go the -[Configuration]({{< relref "installation/configuration.md" >}}) page for details on all -those options. - - -## Getting Started - -- [Getting Started]({{< relref "guides/getting_started.md" >}}) -- [Basic Concepts]({{< relref "guides/basic_concepts.md" >}}) -- [Screencasts]({{< relref "tutorials/screencasts.md" >}}) - -## Data Source Guides - -- [Graphite]({{< relref "features/datasources/graphite.md" >}}) -- [Elasticsearch]({{< relref "features/datasources/elasticsearch.md" >}}) -- [InfluxDB]({{< relref "features/datasources/influxdb.md" >}}) -- [Prometheus]({{< relref "features/datasources/prometheus.md" >}}) -- [OpenTSDB]({{< relref "features/datasources/opentsdb.md" >}}) -- [MySQL]({{< relref "features/datasources/mysql.md" >}}) -- [Postgres]({{< relref "features/datasources/postgres.md" >}}) -- [Cloudwatch]({{< relref "features/datasources/cloudwatch.md" >}}) +

Data Source Guides

+ diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index b742e96c869..bfc7fdc0a3d 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,6 +16,7 @@ weight = 1 Description | Download ------------ | ------------- 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) +Beta for Debian-based Linux | [grafana_5.0.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -27,6 +28,15 @@ installation. 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.3_amd64.deb +``` + +## Install Latest Beta + +```bash +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb +sudo apt-get install -y adduser libfontconfig +sudo dpkg -i grafana_5.0.0-beta1_amd64.deb + ``` ## APT Repository diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index d3e796a78c8..f0c498c819f 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -16,6 +16,7 @@ weight = 2 Description | Download ------------ | ------------- 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) +Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -28,6 +29,12 @@ You can install Grafana using Yum directly. $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm ``` +## Install Beta + +```bash +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm +``` + Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 6a4b4e8f047..af40c20a40b 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -101,3 +101,8 @@ as this will make upgrades easier without risking losing your config changes. ## Upgrading from 2.x We are not aware of any issues upgrading directly from 2.x to 4.x but to be on the safe side go via 3.x => 4.x. + +## Upgrading to v5.0 + +The dashboard grid layout engine has changed. All dashboards will be automatically upgraded to new +positioning system when you load them in v5. Dashboards saved in v5 will not work in older versions of Grafana. diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 7c6a97085df..08d234d63f9 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -14,6 +14,7 @@ weight = 3 Description | Download ------------ | ------------- 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) +Latest beta package for Windows | [grafana.5.0.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index 0149f06e1aa..09a005ba714 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -84,15 +84,15 @@ An array of: { "target":"upper_75", "datapoints":[ - [622,1450754160000], - [365,1450754220000] + [622, 1450754160000], + [365, 1450754220000] ] }, { "target":"upper_90", "datapoints":[ - [861,1450754160000], - [767,1450754220000] + [861, 1450754160000], + [767, 1450754220000] ] } ] diff --git a/package.json b/package.json index acb992a0936..aad7d2ce92c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.0.0-pre1", + "version": "5.0.0-beta1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 276193ad63f..ca5e7aea90c 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=4.6.0-beta1 -rpm_ver=4.6.0-beta1 +deb_ver=5.0.0-beta1 +rpm_ver=5.0.0-beta1 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 1fc3d893681..16f5f7ceb6f 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -105,7 +105,7 @@ func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.Ale for _, alert := range alertDTOs { for _, dash := range dashboardsQuery.Result { if alert.DashboardId == dash.Id { - alert.DashbboardUri = dash.GenerateUrl() + alert.Url = dash.GenerateUrl() break } } diff --git a/pkg/api/api.go b/pkg/api/api.go index 0969191d551..f51ee9824ed 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -150,13 +150,13 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/teams", func(teamsRoute RouteRegister) { teamsRoute.Get("/:teamId", wrap(GetTeamById)) teamsRoute.Get("/search", wrap(SearchTeams)) - teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam)) - teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById)) - teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember)) - }) + 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) { diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 2894692a4c7..54ce082a460 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path" + "strings" "github.com/grafana/grafana/pkg/services/dashboards" @@ -99,7 +100,7 @@ func GetDashboard(c *middleware.Context) Response { return ApiError(500, "Dashboard folder could not be read", err) } meta.FolderTitle = query.Result.Title - meta.FolderSlug = query.Result.Slug + meta.FolderUrl = query.Result.GetUrl() } // make sure db version is in sync with json model version @@ -218,6 +219,10 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil) } + if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(m.RootFolderName) { + return ApiError(400, "A folder already exists with that name", nil) + } + if dash.Id == 0 { limitReached, err := middleware.QuotaReached(c, "dashboard") if err != nil { @@ -228,7 +233,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { } } - dashItem := &dashboards.SaveDashboardItem{ + dashItem := &dashboards.SaveDashboardDTO{ Dashboard: dash, Message: cmd.Message, OrgId: c.OrgId, @@ -238,8 +243,11 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem) - if err == m.ErrDashboardTitleEmpty { - return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil) + if err == m.ErrDashboardTitleEmpty || + err == m.ErrDashboardWithSameNameAsFolder || + err == m.ErrDashboardFolderWithSameNameAsDashboard || + err == m.ErrDashboardTypeMismatch { + return ApiError(400, err.Error(), nil) } if err == m.ErrDashboardContainsInvalidAlertData { @@ -294,10 +302,11 @@ func GetHomeDashboard(c *middleware.Context) Response { } if prefsQuery.Result.HomeDashboardId != 0 { - slugQuery := m.GetDashboardSlugByIdQuery{Id: prefsQuery.Result.HomeDashboardId} + slugQuery := m.GetDashboardRefByIdQuery{Id: prefsQuery.Result.HomeDashboardId} err := bus.Dispatch(&slugQuery) if err == nil { - dashRedirect := dtos.DashboardRedirect{RedirectUri: "db/" + slugQuery.Result} + url := m.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug) + dashRedirect := dtos.DashboardRedirect{RedirectUri: url} return Json(200, &dashRedirect) } else { log.Warn("Failed to get slug from database, %s", err.Error()) diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go index 6eb11047723..45f121dd0d0 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_acl.go @@ -13,6 +13,11 @@ import ( func GetDashboardAclList(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") + _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") + if rsp != nil { + return rsp + } + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { @@ -24,12 +29,23 @@ func GetDashboardAclList(c *middleware.Context) Response { return ApiError(500, "Failed to get dashboard acl", err) } + for _, perm := range acl { + if perm.Slug != "" { + perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) + } + } + return Json(200, acl) } func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { dashId := c.ParamsInt64(":dashboardId") + _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") + if rsp != nil { + return rsp + } + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { return dashboardGuardianResponse(err) @@ -73,6 +89,11 @@ func DeleteDashboardAcl(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") aclId := c.ParamsInt64(":aclId") + _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") + if rsp != nil { + return rsp + } + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { return dashboardGuardianResponse(err) diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index 467045e360a..e43e57ed5c0 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_acl_test.go @@ -23,6 +23,14 @@ func TestDashboardAclApiEndpoint(t *testing.T) { } dtoRes := transformDashboardAclsToDTOs(mockResult) + getDashboardQueryResult := m.NewDashboard("Dash") + var getDashboardNotFoundError error + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return getDashboardNotFoundError + }) + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { query.Result = dtoRes return nil @@ -60,6 +68,40 @@ func TestDashboardAclApiEndpoint(t *testing.T) { So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) }) }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { + getDashboardNotFoundError = m.ErrDashboardNotFound + sc.handlerFunc = GetDashboardAclList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + Convey("Should not be able to access ACL", func() { + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("Should not be able to update permissions for non-existing dashboard", func() { + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) { + getDashboardNotFoundError = m.ErrDashboardNotFound + CallPostAcl(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/2/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_ADMIN, func(sc *scenarioContext) { + getDashboardNotFoundError = m.ErrDashboardNotFound + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + Convey("Should not be able to delete non-existing dashboard", func() { + So(sc.resp.Code, ShouldEqual, 404) + }) + }) }) Convey("When user is org editor and has admin permission in the ACL", func() { diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 87e1eac4113..9ffbace060a 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -17,15 +17,25 @@ import ( ) type fakeDashboardRepo struct { - inserted []*dashboards.SaveDashboardItem + inserted []*dashboards.SaveDashboardDTO + provisioned []*m.DashboardProvisioning getDashboard []*m.Dashboard } -func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*m.Dashboard, error) { +func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardDTO) (*m.Dashboard, error) { repo.inserted = append(repo.inserted, json) return json.Dashboard, nil } +func (repo *fakeDashboardRepo) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *m.DashboardProvisioning) (*m.Dashboard, error) { + repo.inserted = append(repo.inserted, dto) + return dto.Dashboard, nil +} + +func (repo *fakeDashboardRepo) GetProvisionedDashboardData(name string) ([]*m.DashboardProvisioning, error) { + return repo.provisioned, nil +} + var fakeRepo *fakeDashboardRepo // This tests two main scenarios. If a user has access to execute an action on a dashboard: diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index c32edb6e51c..d30f2697f3f 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -19,7 +19,7 @@ type AlertRule struct { EvalDate time.Time `json:"evalDate"` EvalData *simplejson.Json `json:"evalData"` ExecutionError string `json:"executionError"` - DashbboardUri string `json:"dashboardUri"` + Url string `json:"url"` CanEdit bool `json:"canEdit"` } diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 44fc68e4659..e4c66aebbda 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -27,7 +27,7 @@ type DashboardMeta struct { IsFolder bool `json:"isFolder"` FolderId int64 `json:"folderId"` FolderTitle string `json:"folderTitle"` - FolderSlug string `json:"folderSlug"` + FolderUrl string `json:"folderUrl"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 5358caa4b61..49924582388 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -91,13 +91,13 @@ func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { return ApiError(403, "Quota reached", nil) } - dashItem := &dashboards.SaveDashboardItem{ + saveDashboardDto := &dashboards.SaveDashboardDTO{ Dashboard: dashFolder, OrgId: c.OrgId, UserId: c.UserId, } - folder, err := dashboards.GetRepository().SaveDashboard(dashItem) + folder, err := dashboards.GetRepository().SaveDashboard(saveDashboardDto) if err != nil { return toFolderError(err) @@ -127,14 +127,14 @@ func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) } - dashItem := &dashboards.SaveDashboardItem{ + saveDashboardDto := &dashboards.SaveDashboardDTO{ Dashboard: dashFolder, OrgId: c.OrgId, UserId: c.UserId, Overwrite: cmd.Overwrite, } - folder, err := dashboards.GetRepository().SaveDashboard(dashItem) + folder, err := dashboards.GetRepository().SaveDashboard(saveDashboardDto) if err != nil { return toFolderError(err) diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 22fcafc8b29..341ff212f10 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -40,14 +40,14 @@ func GenStateString() string { func OAuthLogin(ctx *middleware.Context) { if setting.OAuthService == nil { - ctx.Handle(404, "login.OAuthLogin(oauth service not enabled)", nil) + ctx.Handle(404, "OAuth not enabled", nil) return } name := ctx.Params(":name") connect, ok := social.SocialMap[name] if !ok { - ctx.Handle(404, "login.OAuthLogin(social login not enabled)", errors.New(name)) + ctx.Handle(404, fmt.Sprintf("No OAuth with name %s configured", name), nil) return } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 57a15bd8db5..433b9f2bd66 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -46,26 +46,30 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { // GET /api/org/users func GetOrgUsersForCurrentOrg(c *middleware.Context) Response { - return getOrgUsersHelper(c.OrgId) + return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit")) } // GET /api/orgs/:orgId/users func GetOrgUsers(c *middleware.Context) Response { - return getOrgUsersHelper(c.ParamsInt64(":orgId")) + return getOrgUsersHelper(c.ParamsInt64(":orgId"), "", 0) } -func getOrgUsersHelper(orgId int64) Response { - query := m.GetOrgUsersQuery{OrgId: orgId} +func getOrgUsersHelper(orgId int64, query string, limit int) Response { + q := m.GetOrgUsersQuery{ + OrgId: orgId, + Query: query, + Limit: limit, + } - if err := bus.Dispatch(&query); err != nil { + if err := bus.Dispatch(&q); err != nil { return ApiError(500, "Failed to get account user", err) } - for _, user := range query.Result { + for _, user := range q.Result { user.AvatarUrl = dtos.GetGravatarUrl(user.Email) } - return Json(200, query.Result) + return Json(200, q.Result) } // PATCH /api/org/users/:userId diff --git a/pkg/api/team.go b/pkg/api/team.go index af537224d41..f11eca68b91 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -26,6 +26,7 @@ func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response { // PUT /api/teams/:teamId func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { + cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { @@ -39,7 +40,7 @@ func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { // DELETE /api/teams/:teamId func DeleteTeamById(c *middleware.Context) Response { - if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil { + if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil { if err == m.ErrTeamNotFound { return ApiError(404, "Failed to delete Team. ID not found", nil) } @@ -60,11 +61,11 @@ func SearchTeams(c *middleware.Context) Response { } query := m.SearchTeamsQuery{ + OrgId: c.OrgId, Query: c.Query("query"), Name: c.Query("name"), Page: page, Limit: perPage, - OrgId: c.OrgId, } if err := bus.Dispatch(&query); err != nil { @@ -83,7 +84,7 @@ func SearchTeams(c *middleware.Context) Response { // GET /api/teams/:teamId func GetTeamById(c *middleware.Context) Response { - query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")} + query := m.GetTeamByIdQuery{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")} if err := bus.Dispatch(&query); err != nil { if err == m.ErrTeamNotFound { diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 412e142edb7..59dfc20b791 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -10,7 +10,7 @@ import ( // GET /api/teams/:teamId/members func GetTeamMembers(c *middleware.Context) Response { - query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")} + query := m.GetTeamMembersQuery{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId")} if err := bus.Dispatch(&query); err != nil { return ApiError(500, "Failed to get Team Members", err) @@ -42,7 +42,7 @@ func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response { // 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 { + if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, 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/middleware/auth.go b/pkg/middleware/auth.go index be3415d990b..826287e12f3 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -42,8 +42,7 @@ func accessForbidden(c *Context) { return } - c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/") - c.Redirect(setting.AppSubUrl + "/login") + c.Redirect(setting.AppSubUrl + "/") } func notAuthorized(c *Context) { diff --git a/pkg/middleware/dashboard_redirect.go b/pkg/middleware/dashboard_redirect.go index 1ca4ef741c6..4a3812fb8a2 100644 --- a/pkg/middleware/dashboard_redirect.go +++ b/pkg/middleware/dashboard_redirect.go @@ -1,6 +1,7 @@ package middleware import ( + "fmt" "strings" "github.com/grafana/grafana/pkg/bus" @@ -24,6 +25,7 @@ func RedirectFromLegacyDashboardUrl() macaron.Handler { if slug != "" { if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil { + url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery) c.Redirect(url, 301) return } @@ -38,6 +40,7 @@ func RedirectFromLegacyDashboardSoloUrl() macaron.Handler { if slug != "" { if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil { url = strings.Replace(url, "/d/", "/d-solo/", 1) + url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery) c.Redirect(url, 301) return } diff --git a/pkg/middleware/dashboard_redirect_test.go b/pkg/middleware/dashboard_redirect_test.go index 21fc12e5e84..0af06347ed0 100644 --- a/pkg/middleware/dashboard_redirect_test.go +++ b/pkg/middleware/dashboard_redirect_test.go @@ -30,19 +30,20 @@ func TestMiddlewareDashboardRedirect(t *testing.T) { middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) { sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler) - sc.fakeReqWithParams("GET", "/dashboard/db/dash", map[string]string{}).exec() + sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec() Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { So(sc.resp.Code, ShouldEqual, 301) redirectUrl, _ := sc.resp.Result().Location() So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)) + So(len(redirectUrl.Query()), ShouldEqual, 2) }) }) middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) { sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler) - sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash", map[string]string{}).exec() + sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec() Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { So(sc.resp.Code, ShouldEqual, 301) @@ -50,6 +51,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) { expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug) expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1) So(redirectUrl.Path, ShouldEqual, expectedUrl) + So(len(redirectUrl.Query()), ShouldEqual, 2) }) }) }) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 259d800f0a9..f0c952811cd 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -206,7 +206,9 @@ func (ctx *Context) Handle(status int, title string, err error) { ctx.Data["Title"] = title ctx.Data["AppSubUrl"] = setting.AppSubUrl - ctx.HTML(status, strconv.Itoa(status)) + ctx.Data["Theme"] = "dark" + + ctx.HTML(status, "error") } func (ctx *Context) JsonOK(message string) { diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 0c9dc4670e2..388acc15afc 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -137,7 +137,7 @@ func Recovery() macaron.Handler { c.JSON(500, resp) } else { - c.HTML(500, "500") + c.HTML(500, "error") } } }() diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index fa7ad00de7f..933487650e3 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -59,6 +59,11 @@ type DashboardAclInfoDTO struct { Role *RoleType `json:"role,omitempty"` Permission PermissionType `json:"permission"` PermissionName string `json:"permissionName"` + Uid string `json:"uid"` + Title string `json:"title"` + Slug string `json:"slug"` + IsFolder bool `json:"isFolder"` + Url string `json:"url"` } // diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 430f74ef75a..4516bd0dd74 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -13,17 +13,22 @@ import ( // Typed errors var ( - ErrDashboardNotFound = errors.New("Dashboard not found") - ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") - ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") - ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") - ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") - ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") - ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") - ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") - ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") - ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") - ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") + ErrDashboardNotFound = errors.New("Dashboard not found") + ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") + ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") + ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") + ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") + ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") + ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") + ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") + ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") + ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") + ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") + ErrDashboardExistingCannotChangeToDashboard = errors.New("An existing folder cannot be changed to a dashboard") + ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") + ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") + ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") + RootFolderName = "General" ) type UpdatePluginDashboardError struct { @@ -64,6 +69,11 @@ type Dashboard struct { Data *simplejson.Json } +func (d *Dashboard) SetId(id int64) { + d.Id = id + d.Data.Set("id", id) +} + // NewDashboard creates a new dashboard func NewDashboard(title string) *Dashboard { dash := &Dashboard{} @@ -95,14 +105,21 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard { dash.Data = data dash.Title = dash.Data.Get("title").MustString() dash.UpdateSlug() + update := false if id, err := dash.Data.Get("id").Float64(); err == nil { dash.Id = int64(id) + update = true + } - if version, err := dash.Data.Get("version").Float64(); err == nil { - dash.Version = int(version) - dash.Updated = time.Now() - } + if uid, err := dash.Data.Get("uid").String(); err == nil { + dash.Uid = uid + update = true + } + + if version, err := dash.Data.Get("version").Float64(); err == nil && update { + dash.Version = int(version) + dash.Updated = time.Now() } else { dash.Data.Set("version", 0) dash.Created = time.Now() @@ -113,10 +130,6 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard { dash.GnetId = int64(gnetId) } - if uid, err := dash.Data.Get("uid").String(); err == nil { - dash.Uid = uid - } - return dash } @@ -211,6 +224,21 @@ type SaveDashboardCommand struct { Result *Dashboard } +type DashboardProvisioning struct { + Id int64 + DashboardId int64 + Name string + ExternalId string + Updated time.Time +} + +type SaveProvisionedDashboardCommand struct { + DashboardCmd *SaveDashboardCommand + DashboardProvisioning *DashboardProvisioning + + Result *Dashboard +} + type DeleteDashboardCommand struct { Id int64 OrgId int64 @@ -263,6 +291,12 @@ type GetDashboardSlugByIdQuery struct { Result string } +type GetProvisionedDashboardDataQuery struct { + Name string + + Result []*DashboardProvisioning +} + type GetDashboardsBySlugQuery struct { OrgId int64 Slug string @@ -281,7 +315,7 @@ type DashboardRef struct { Slug string } -type GetDashboardUIDByIdQuery struct { +type GetDashboardRefByIdQuery struct { Id int64 Result *DashboardRef } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 9379625d458..ca32cc50060 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -95,7 +95,10 @@ type UpdateOrgUserCommand struct { // QUERIES type GetOrgUsersQuery struct { - OrgId int64 + OrgId int64 + Query string + Limit int + Result []*OrgUserDTO } diff --git a/pkg/models/team.go b/pkg/models/team.go index d2912f431b8..f789f125aa1 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -37,18 +37,22 @@ type UpdateTeamCommand struct { Id int64 Name string Email string + OrgId int64 `json:"-"` } type DeleteTeamCommand struct { - Id int64 + OrgId int64 + Id int64 } type GetTeamByIdQuery struct { + OrgId int64 Id int64 Result *Team } type GetTeamsByUserQuery struct { + OrgId int64 UserId int64 `json:"userId"` Result []*Team `json:"teams"` } diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 9970678a1ae..19cf657292d 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -31,6 +31,7 @@ type AddTeamMemberCommand struct { } type RemoveTeamMemberCommand struct { + OrgId int64 `json:"-"` UserId int64 TeamId int64 } @@ -39,6 +40,7 @@ type RemoveTeamMemberCommand struct { // QUERIES type GetTeamMembersQuery struct { + OrgId int64 TeamId int64 Result []*TeamMemberDTO } diff --git a/pkg/plugins/dashboard_importer.go b/pkg/plugins/dashboard_importer.go index bf516818e3c..9036b943b30 100644 --- a/pkg/plugins/dashboard_importer.go +++ b/pkg/plugins/dashboard_importer.go @@ -82,6 +82,7 @@ func ImportDashboard(cmd *ImportDashboardCommand) error { Path: cmd.Path, Revision: dashboard.Data.Get("revision").MustInt64(1), ImportedUri: "db/" + saveCmd.Result.Slug, + ImportedUrl: saveCmd.Result.GetUrl(), ImportedRevision: dashboard.Data.Get("revision").MustInt64(1), Imported: true, } diff --git a/pkg/plugins/dashboards.go b/pkg/plugins/dashboards.go index 37e3d8c0076..d15bcdd6db5 100644 --- a/pkg/plugins/dashboards.go +++ b/pkg/plugins/dashboards.go @@ -14,6 +14,7 @@ type PluginDashboardInfoDTO struct { Title string `json:"title"` Imported bool `json:"imported"` ImportedUri string `json:"importedUri"` + ImportedUrl string `json:"importedUrl"` Slug string `json:"slug"` DashboardId int64 `json:"dashboardId"` ImportedRevision int64 `json:"importedRevision"` @@ -64,6 +65,7 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT res.DashboardId = existingDash.Id res.Imported = true res.ImportedUri = "db/" + existingDash.Slug + res.ImportedUrl = existingDash.GetUrl() res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1) existingMatches[existingDash.Id] = true } diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index 60c5530d486..d598203d675 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -90,7 +90,7 @@ func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) { return c.dashboardRef, nil } - uidQuery := &m.GetDashboardUIDByIdQuery{Id: c.Rule.DashboardId} + uidQuery := &m.GetDashboardRefByIdQuery{Id: c.Rule.DashboardId} if err := bus.Dispatch(uidQuery); err != nil { return nil, err } diff --git a/pkg/services/dashboards/dashboards.go b/pkg/services/dashboards/dashboards.go index 4bdba59b18e..b0392f7944f 100644 --- a/pkg/services/dashboards/dashboards.go +++ b/pkg/services/dashboards/dashboards.go @@ -9,7 +9,9 @@ import ( ) type Repository interface { - SaveDashboard(*SaveDashboardItem) (*models.Dashboard, error) + SaveDashboard(*SaveDashboardDTO) (*models.Dashboard, error) + SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) + GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) } var repositoryInstance Repository @@ -22,7 +24,7 @@ func SetRepository(rep Repository) { repositoryInstance = rep } -type SaveDashboardItem struct { +type SaveDashboardDTO struct { OrgId int64 UpdatedAt time.Time UserId int64 @@ -33,15 +35,25 @@ type SaveDashboardItem struct { type DashboardRepository struct{} -func (dr *DashboardRepository) SaveDashboard(json *SaveDashboardItem) (*models.Dashboard, error) { - dashboard := json.Dashboard +func (dr *DashboardRepository) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + cmd := &models.GetProvisionedDashboardDataQuery{Name: name} + err := bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *DashboardRepository) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) { + dashboard := dto.Dashboard if dashboard.Title == "" { return nil, models.ErrDashboardTitleEmpty } validateAlertsCmd := alerting.ValidateDashboardAlertsCommand{ - OrgId: json.OrgId, + OrgId: dto.OrgId, Dashboard: dashboard, } @@ -49,33 +61,77 @@ func (dr *DashboardRepository) SaveDashboard(json *SaveDashboardItem) (*models.D return nil, models.ErrDashboardContainsInvalidAlertData } - cmd := models.SaveDashboardCommand{ + cmd := &models.SaveDashboardCommand{ Dashboard: dashboard.Data, - Message: json.Message, - OrgId: json.OrgId, - Overwrite: json.Overwrite, - UserId: json.UserId, + Message: dto.Message, + OrgId: dto.OrgId, + Overwrite: dto.Overwrite, + UserId: dto.UserId, FolderId: dashboard.FolderId, IsFolder: dashboard.IsFolder, } - if !json.UpdatedAt.IsZero() { - cmd.UpdatedAt = json.UpdatedAt + if !dto.UpdatedAt.IsZero() { + cmd.UpdatedAt = dto.UpdatedAt } - err := bus.Dispatch(&cmd) - if err != nil { - return nil, err - } + return cmd, nil +} +func (dr *DashboardRepository) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error { alertCmd := alerting.UpdateDashboardAlertsCommand{ - OrgId: json.OrgId, - UserId: json.UserId, + OrgId: dto.OrgId, + UserId: dto.UserId, Dashboard: cmd.Result, } if err := bus.Dispatch(&alertCmd); err != nil { - return nil, models.ErrDashboardFailedToUpdateAlertData + return models.ErrDashboardFailedToUpdateAlertData + } + + return nil +} + +func (dr *DashboardRepository) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + cmd, err := dr.buildSaveDashboardCommand(dto) + if err != nil { + return nil, err + } + + saveCmd := &models.SaveProvisionedDashboardCommand{ + DashboardCmd: cmd, + DashboardProvisioning: provisioning, + } + + // dashboard + err = bus.Dispatch(saveCmd) + if err != nil { + return nil, err + } + + //alerts + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *DashboardRepository) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + cmd, err := dr.buildSaveDashboardCommand(dto) + if err != nil { + return nil, err + } + + err = bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err } return cmd.Result, nil diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index f4056841c33..b448561494d 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -160,7 +160,7 @@ func (g *DashboardGuardian) getTeams() ([]*m.Team, error) { return g.groups, nil } - query := m.GetTeamsByUserQuery{UserId: g.user.UserId} + query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId} err := bus.Dispatch(&query) g.groups = query.Result diff --git a/pkg/services/provisioning/dashboards/dashboard_cache.go b/pkg/services/provisioning/dashboards/dashboard_cache.go deleted file mode 100644 index da6b7e8a5e8..00000000000 --- a/pkg/services/provisioning/dashboards/dashboard_cache.go +++ /dev/null @@ -1,33 +0,0 @@ -package dashboards - -import ( - "github.com/grafana/grafana/pkg/services/dashboards" - gocache "github.com/patrickmn/go-cache" - "time" -) - -type dashboardCache struct { - internalCache *gocache.Cache -} - -func NewDashboardCache() *dashboardCache { - return &dashboardCache{internalCache: gocache.New(5*time.Minute, 30*time.Minute)} -} - -func (fr *dashboardCache) addDashboardCache(key string, json *dashboards.SaveDashboardItem) { - fr.internalCache.Add(key, json, time.Minute*10) -} - -func (fr *dashboardCache) getCache(key string) (*dashboards.SaveDashboardItem, bool) { - obj, exist := fr.internalCache.Get(key) - if !exist { - return nil, exist - } - - dash, ok := obj.(*dashboards.SaveDashboardItem) - if !ok { - return nil, ok - } - - return dash, ok -} diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index fbe1a03e287..c67f355a36e 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -29,8 +29,6 @@ type fileReader struct { Path string log log.Logger dashboardRepo dashboards.Repository - cache *dashboardCache - createWalk func(fr *fileReader, folderId int64) filepath.WalkFunc } func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) { @@ -54,24 +52,22 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade Path: path, log: log, dashboardRepo: dashboards.GetRepository(), - cache: NewDashboardCache(), - createWalk: createWalkFn, }, nil } func (fr *fileReader) ReadAndListen(ctx context.Context) error { - ticker := time.NewTicker(checkDiskForChangesInterval) - if err := fr.startWalkingDisk(); err != nil { fr.log.Error("failed to search for dashboards", "error", err) } + ticker := time.NewTicker(checkDiskForChangesInterval) + running := false for { select { case <-ticker.C: - if !running { // avoid walking the filesystem in parallel. incase fs is very slow. + if !running { // avoid walking the filesystem in parallel. in-case fs is very slow. running = true go func() { if err := fr.startWalkingDisk(); err != nil { @@ -98,7 +94,91 @@ func (fr *fileReader) startWalkingDisk() error { return err } - return filepath.Walk(fr.Path, fr.createWalk(fr, folderId)) + provisionedDashboardRefs, err := getProvisionedDashboardByPath(fr.dashboardRepo, fr.Cfg.Name) + if err != nil { + return err + } + + filesFoundOnDisk := map[string]os.FileInfo{} + err = filepath.Walk(fr.Path, createWalkFn(filesFoundOnDisk)) + if err != nil { + return err + } + + // find dashboards to delete since json file is missing + var dashboardToDelete []int64 + for path, provisioningData := range provisionedDashboardRefs { + _, existsOnDisk := filesFoundOnDisk[path] + if !existsOnDisk { + dashboardToDelete = append(dashboardToDelete, provisioningData.DashboardId) + } + } + + // delete dashboard that are missing json file + for _, dashboardId := range dashboardToDelete { + fr.log.Debug("deleting provisioned dashboard. missing on disk", "id", dashboardId) + cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId} + err := bus.Dispatch(cmd) + if err != nil { + fr.log.Error("failed to delete dashboard", "id", cmd.Id) + } + } + + // save dashboards based on json files + for path, fileInfo := range filesFoundOnDisk { + err = fr.saveDashboard(path, folderId, fileInfo, provisionedDashboardRefs) + if err != nil { + fr.log.Error("failed to save dashboard", "error", err) + } + } + + return nil +} + +func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.FileInfo, provisionedDashboardRefs map[string]*models.DashboardProvisioning) error { + resolvedFileInfo, err := resolveSymlink(fileInfo, path) + if err != nil { + return err + } + + provisionedData, alreadyProvisioned := provisionedDashboardRefs[path] + if alreadyProvisioned && provisionedData.Updated.Unix() == resolvedFileInfo.ModTime().Unix() { + return nil // dashboard is already in sync with the database + } + + dash, err := fr.readDashboardFromFile(path, resolvedFileInfo.ModTime(), folderId) + if err != nil { + fr.log.Error("failed to load dashboard from ", "file", path, "error", err) + return nil + } + + if dash.Dashboard.Id != 0 { + fr.log.Error("provisioned dashboard json files cannot contain id") + return nil + } + + if alreadyProvisioned { + dash.Dashboard.SetId(provisionedData.DashboardId) + } + + fr.log.Debug("saving new dashboard", "file", path) + dp := &models.DashboardProvisioning{ExternalId: path, Name: fr.Cfg.Name, Updated: resolvedFileInfo.ModTime()} + _, err = fr.dashboardRepo.SaveProvisionedDashboard(dash, dp) + return err +} + +func getProvisionedDashboardByPath(repo dashboards.Repository, name string) (map[string]*models.DashboardProvisioning, error) { + arr, err := repo.GetProvisionedDashboardData(name) + if err != nil { + return nil, err + } + + byPath := map[string]*models.DashboardProvisioning{} + for _, pd := range arr { + byPath[pd.ExternalId] = pd + } + + return byPath, nil } func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (int64, error) { @@ -115,7 +195,7 @@ func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (i // dashboard folder not found. create one. if err == models.ErrDashboardNotFound { - dash := &dashboards.SaveDashboardItem{} + dash := &dashboards.SaveDashboardDTO{} dash.Dashboard = models.NewDashboard(cfg.Folder) dash.Dashboard.IsFolder = true dash.Overwrite = true @@ -129,83 +209,59 @@ func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (i } if !cmd.Result.IsFolder { - return 0, fmt.Errorf("Got invalid response. Expected folder, found dashboard") + return 0, fmt.Errorf("got invalid response. expected folder, found dashboard") } return cmd.Result.Id, nil } -func createWalkFn(fr *fileReader, folderId int64) filepath.WalkFunc { +func resolveSymlink(fileinfo os.FileInfo, path string) (os.FileInfo, error) { + checkFilepath, err := filepath.EvalSymlinks(path) + if path != checkFilepath { + path = checkFilepath + fi, err := os.Lstat(checkFilepath) + if err != nil { + return nil, err + } + + return fi, nil + } + + return fileinfo, err +} + +func createWalkFn(filesOnDisk map[string]os.FileInfo) filepath.WalkFunc { return func(path string, fileInfo os.FileInfo, err error) error { if err != nil { return err } - if fileInfo.IsDir() { - if strings.HasPrefix(fileInfo.Name(), ".") { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(fileInfo.Name(), ".json") { - return nil - } - - checkFilepath, err := filepath.EvalSymlinks(path) - - if path != checkFilepath { - path = checkFilepath - fi, err := os.Lstat(checkFilepath) - if err != nil { - return err - } - fileInfo = fi - } - - cachedDashboard, exist := fr.cache.getCache(path) - if exist && cachedDashboard.UpdatedAt == fileInfo.ModTime() { - return nil - } - - dash, err := fr.readDashboardFromFile(path, folderId) - if err != nil { - fr.log.Error("failed to load dashboard from ", "file", path, "error", err) - return nil - } - - if dash.Dashboard.Id != 0 { - fr.log.Error("Cannot provision dashboard. Please remove the id property from the json file") - return nil - } - - cmd := &models.GetDashboardQuery{Slug: dash.Dashboard.Slug} - err = bus.Dispatch(cmd) - - // if we don't have the dashboard in the db, save it! - if err == models.ErrDashboardNotFound { - fr.log.Debug("saving new dashboard", "file", path) - _, err = fr.dashboardRepo.SaveDashboard(dash) + isValid, err := validateWalkablePath(fileInfo) + if !isValid { return err } - if err != nil { - fr.log.Error("failed to query for dashboard", "slug", dash.Dashboard.Slug, "error", err) - return nil - } - - // break if db version is newer then fil version - if cmd.Result.Updated.Unix() >= fileInfo.ModTime().Unix() { - return nil - } - - fr.log.Debug("loading dashboard from disk into database.", "file", path) - _, err = fr.dashboardRepo.SaveDashboard(dash) - - return err + filesOnDisk[path] = fileInfo + return nil } } -func (fr *fileReader) readDashboardFromFile(path string, folderId int64) (*dashboards.SaveDashboardItem, error) { +func validateWalkablePath(fileInfo os.FileInfo) (bool, error) { + if fileInfo.IsDir() { + if strings.HasPrefix(fileInfo.Name(), ".") { + return false, filepath.SkipDir + } + return false, nil + } + + if !strings.HasSuffix(fileInfo.Name(), ".json") { + return false, nil + } + + return true, nil +} + +func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time, folderId int64) (*dashboards.SaveDashboardDTO, error) { reader, err := os.Open(path) if err != nil { return nil, err @@ -217,17 +273,10 @@ func (fr *fileReader) readDashboardFromFile(path string, folderId int64) (*dashb return nil, err } - stat, err := os.Stat(path) + dash, err := createDashboardJson(data, lastModified, fr.Cfg, folderId) if err != nil { return nil, err } - dash, err := createDashboardJson(data, stat.ModTime(), fr.Cfg, folderId) - if err != nil { - return nil, err - } - - fr.cache.addDashboardCache(path, dash) - return dash, nil } diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index f2805196dde..a81b502c50a 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -62,25 +62,8 @@ func TestDashboardFileReader(t *testing.T) { } } - So(dashboards, ShouldEqual, 2) So(folders, ShouldEqual, 1) - }) - - Convey("Should not update dashboards when db is newer", func() { - cfg.Options["path"] = oneDashboard - - fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{ - Updated: time.Now().Add(time.Hour), - Slug: "grafana", - }) - - reader, err := NewDashboardFileReader(cfg, logger) - So(err, ShouldBeNil) - - err = reader.startWalkingDisk() - So(err, ShouldBeNil) - - So(len(fakeRepo.inserted), ShouldEqual, 0) + So(dashboards, ShouldEqual, 2) }) Convey("Can read default dashboard and replace old version in database", func() { @@ -161,26 +144,15 @@ func TestDashboardFileReader(t *testing.T) { }) Convey("Walking the folder with dashboards", func() { - cfg := &DashboardsAsConfig{ - Name: "Default", - Type: "file", - OrgId: 1, - Folder: "", - Options: map[string]interface{}{ - "path": defaultDashboards, - }, - } - - reader, err := NewDashboardFileReader(cfg, log.New("test-logger")) - So(err, ShouldBeNil) + noFiles := map[string]os.FileInfo{} Convey("should skip dirs that starts with .", func() { - shouldSkip := reader.createWalk(reader, 0)("path", &FakeFileInfo{isDirectory: true, name: ".folder"}, nil) + shouldSkip := createWalkFn(noFiles)("path", &FakeFileInfo{isDirectory: true, name: ".folder"}, nil) So(shouldSkip, ShouldEqual, filepath.SkipDir) }) Convey("should keep walking if file is not .json", func() { - shouldSkip := reader.createWalk(reader, 0)("path", &FakeFileInfo{isDirectory: true, name: "folder"}, nil) + shouldSkip := createWalkFn(noFiles)("path", &FakeFileInfo{isDirectory: true, name: "folder"}, nil) So(shouldSkip, ShouldBeNil) }) }) @@ -241,15 +213,26 @@ func (ffi FakeFileInfo) Sys() interface{} { } type fakeDashboardRepo struct { - inserted []*dashboards.SaveDashboardItem + inserted []*dashboards.SaveDashboardDTO + provisioned []*models.DashboardProvisioning getDashboard []*models.Dashboard } -func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*models.Dashboard, error) { +func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { repo.inserted = append(repo.inserted, json) return json.Dashboard, nil } +func (repo *fakeDashboardRepo) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + return repo.provisioned, nil +} + +func (repo *fakeDashboardRepo) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + repo.inserted = append(repo.inserted, dto) + repo.provisioned = append(repo.provisioned, provisioning) + return dto.Dashboard, nil +} + func mockGetDashboardQuery(cmd *models.GetDashboardQuery) error { for _, d := range fakeRepo.getDashboard { if d.Slug == cmd.Slug { diff --git a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json index 5b6765a4ed6..febb98be0e8 100644 --- a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json +++ b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json @@ -1,5 +1,5 @@ { - "title": "Grafana", + "title": "Grafana1", "tags": [], "style": "dark", "timezone": "browser", @@ -170,4 +170,3 @@ }, "version": 5 } - \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json index 5b6765a4ed6..9291f16d9e7 100644 --- a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json +++ b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json @@ -1,5 +1,5 @@ { - "title": "Grafana", + "title": "Grafana2", "tags": [], "style": "dark", "timezone": "browser", @@ -170,4 +170,3 @@ }, "version": 5 } - \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index cf65c65348c..91379b33148 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -18,8 +18,8 @@ type DashboardsAsConfig struct { Options map[string]interface{} `json:"options" yaml:"options"` } -func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardItem, error) { - dash := &dashboards.SaveDashboardItem{} +func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardDTO, error) { + dash := &dashboards.SaveDashboardDTO{} dash.Dashboard = models.NewDashboardFromJson(data) dash.UpdatedAt = lastModified dash.Overwrite = true diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go index f5d87b1f5c8..6dea975d9fe 100644 --- a/pkg/services/search/models.go +++ b/pkg/services/search/models.go @@ -21,8 +21,9 @@ type Hit struct { Tags []string `json:"tags"` IsStarred bool `json:"isStarred"` FolderId int64 `json:"folderId,omitempty"` + FolderUid string `json:"folderUid,omitempty"` FolderTitle string `json:"folderTitle,omitempty"` - FolderSlug string `json:"folderSlug,omitempty"` + FolderUrl string `json:"folderUrl,omitempty"` } type HitList []*Hit diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index f9298c36594..dfe8f1a0014 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -30,143 +30,198 @@ var generateNewUid func() string = util.GenerateShortUid func SaveDashboard(cmd *m.SaveDashboardCommand) error { return inTransaction(func(sess *DBSession) error { - dash := cmd.GetDashboardModel() + return saveDashboard(sess, cmd) + }) +} - // try get existing dashboard - var existing m.Dashboard +func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { + dash := cmd.GetDashboardModel() - if dash.Id != 0 { - dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) - if err != nil { - return err - } - if !dashWithIdExists { - return m.ErrDashboardNotFound + if err := getExistingDashboardForUpdate(sess, dash, cmd); err != nil { + return err + } + + var existingByTitleAndFolder m.Dashboard + + dashWithTitleAndFolderExists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, dialect.BooleanStr(true), dash.FolderId).Get(&existingByTitleAndFolder) + if err != nil { + return err + } + + if dashWithTitleAndFolderExists { + if dash.Id != existingByTitleAndFolder.Id { + if existingByTitleAndFolder.IsFolder && !cmd.IsFolder { + return m.ErrDashboardWithSameNameAsFolder } - // check for is someone else has written in between - if dash.Version != existing.Version { - if cmd.Overwrite { - dash.Version = existing.Version - } else { - return m.ErrDashboardVersionMismatch - } - } - - // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && cmd.Overwrite == false { - return m.UpdatePluginDashboardError{PluginId: existing.PluginId} - } - - dash.Created = existing.Created - dash.CreatedBy = existing.CreatedBy - } else if dash.Uid != "" { - var sameUid m.Dashboard - sameUidExists, err := sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&sameUid) - if err != nil { - return err - } - - if sameUidExists { - // another dashboard with same uid - if dash.Id != sameUid.Id { - if cmd.Overwrite { - dash.Id = sameUid.Id - dash.Version = sameUid.Version - } else { - return m.ErrDashboardWithSameUIDExists - } - } else { - dash.Created = sameUid.Created - dash.CreatedBy = sameUid.CreatedBy + if !existingByTitleAndFolder.IsFolder && cmd.IsFolder { + return m.ErrDashboardFolderWithSameNameAsDashboard + } + + if cmd.Overwrite { + dash.Id = existingByTitleAndFolder.Id + dash.Created = existingByTitleAndFolder.Created + dash.CreatedBy = existingByTitleAndFolder.CreatedBy + dash.Version = existingByTitleAndFolder.Version + + if dash.Uid == "" { + dash.Uid = existingByTitleAndFolder.Uid } + } else { + return m.ErrDashboardWithSameNameInFolderExists } } + } + + if dash.Uid == "" { + uid, err := generateNewDashboardUid(sess, dash.OrgId) + if err != nil { + return err + } + dash.Uid = uid + dash.Data.Set("uid", uid) + } + + err = setHasAcl(sess, dash) + if err != nil { + return err + } + + parentVersion := dash.Version + affectedRows := int64(0) + + if dash.Id == 0 { + dash.Version = 1 + metrics.M_Api_Dashboard_Insert.Inc() + dash.Data.Set("version", dash.Version) + affectedRows, err = sess.Insert(dash) + } else { + dash.Version++ + dash.Data.Set("version", dash.Version) + + if !cmd.UpdatedAt.IsZero() { + dash.Updated = cmd.UpdatedAt + } + + affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash) + } + + if err != nil { + return err + } + + if affectedRows == 0 { + return m.ErrDashboardNotFound + } + + dashVersion := &m.DashboardVersion{ + DashboardId: dash.Id, + ParentVersion: parentVersion, + RestoredFrom: cmd.RestoredFrom, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, + } + + // insert version entry + if affectedRows, err = sess.Insert(dashVersion); err != nil { + return err + } else if affectedRows == 0 { + return m.ErrDashboardNotFound + } + + // delete existing tags + _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) + if err != nil { + return err + } + + // insert new tags + tags := dash.GetTags() + if len(tags) > 0 { + for _, tag := range tags { + if _, err := sess.Insert(&DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { + return err + } + } + } + + cmd.Result = dash + + return err +} + +func getExistingDashboardForUpdate(sess *DBSession, dash *m.Dashboard, cmd *m.SaveDashboardCommand) (err error) { + dashWithIdExists := false + var existingById m.Dashboard + + if dash.Id > 0 { + dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) + if err != nil { + return err + } + + if !dashWithIdExists { + return m.ErrDashboardNotFound + } + + dash.Created = existingById.Created + dash.CreatedBy = existingById.CreatedBy if dash.Uid == "" { - uid, err := generateNewDashboardUid(sess, dash.OrgId) - if err != nil { - return err - } - dash.Uid = uid - dash.Data.Set("uid", uid) + dash.Uid = existingById.Uid } + } - err := guaranteeDashboardNameIsUniqueInFolder(sess, dash) + dashWithUidExists := false + var existingByUid m.Dashboard + + if dash.Uid != "" { + dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) if err != nil { return err } + } - err = setHasAcl(sess, dash) - if err != nil { - return err - } + if !dashWithIdExists && !dashWithUidExists { + return nil + } - parentVersion := dash.Version - affectedRows := int64(0) + if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { + return m.ErrDashboardWithSameUIDExists + } - if dash.Id == 0 { - dash.Version = 1 - metrics.M_Api_Dashboard_Insert.Inc() - dash.Data.Set("version", dash.Version) - affectedRows, err = sess.Insert(dash) + existing := existingById + + if !dashWithIdExists && dashWithUidExists { + dash.Id = existingByUid.Id + dash.Created = existingByUid.Created + dash.CreatedBy = existingByUid.CreatedBy + existing = existingByUid + } + + if (existing.IsFolder && !cmd.IsFolder) || + (!existing.IsFolder && cmd.IsFolder) { + return m.ErrDashboardTypeMismatch + } + + // check for is someone else has written in between + if dash.Version != existing.Version { + if cmd.Overwrite { + dash.Version = existing.Version } else { - dash.Version++ - dash.Data.Set("version", dash.Version) - - if !cmd.UpdatedAt.IsZero() { - dash.Updated = cmd.UpdatedAt - } - - affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash) + return m.ErrDashboardVersionMismatch } + } - if err != nil { - return err - } + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && cmd.Overwrite == false { + return m.UpdatePluginDashboardError{PluginId: existing.PluginId} + } - if affectedRows == 0 { - return m.ErrDashboardNotFound - } - - dashVersion := &m.DashboardVersion{ - DashboardId: dash.Id, - ParentVersion: parentVersion, - RestoredFrom: cmd.RestoredFrom, - Version: dash.Version, - Created: time.Now(), - CreatedBy: dash.UpdatedBy, - Message: cmd.Message, - Data: dash.Data, - } - - // insert version entry - if affectedRows, err = sess.Insert(dashVersion); err != nil { - return err - } else if affectedRows == 0 { - return m.ErrDashboardNotFound - } - - // delete existing tags - _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) - if err != nil { - return err - } - - // insert new tags - tags := dash.GetTags() - if len(tags) > 0 { - for _, tag := range tags { - if _, err := sess.Insert(&DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { - return err - } - } - } - - cmd.Result = dash - - return err - }) + return nil } func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { @@ -186,23 +241,6 @@ func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { return "", m.ErrDashboardFailedGenerateUniqueUid } -func guaranteeDashboardNameIsUniqueInFolder(sess *DBSession, dash *m.Dashboard) error { - var sameNameInFolder m.Dashboard - sameNameInFolderExist, err := sess.Where("org_id=? AND title=? AND folder_id = ? AND uid <> ?", - dash.OrgId, dash.Title, dash.FolderId, dash.Uid). - Get(&sameNameInFolder) - - if err != nil { - return err - } - - if sameNameInFolderExist { - return m.ErrDashboardWithSameNameInFolderExists - } - - return nil -} - func setHasAcl(sess *DBSession, dash *m.Dashboard) error { // check if parent has acl if dash.FolderId > 0 { @@ -252,6 +290,7 @@ type DashboardSearchProjection struct { Term string IsFolder bool FolderId int64 + FolderUid string FolderSlug string FolderTitle string } @@ -330,11 +369,15 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard Url: m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug), Type: getHitType(item), FolderId: item.FolderId, + FolderUid: item.FolderUid, FolderTitle: item.FolderTitle, - FolderSlug: item.FolderSlug, Tags: []string{}, } + if item.FolderId > 0 { + hit.FolderUrl = m.GetFolderUrl(item.FolderUid, item.FolderSlug) + } + query.Result = append(query.Result, hit) hits[item.Id] = hit } @@ -366,8 +409,9 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error { if query.SignedInUser.OrgRole == m.ROLE_ADMIN { sql := `SELECT distinct d.id, d.title - FROM dashboard AS d WHERE d.is_folder = ?` + FROM dashboard AS d WHERE d.is_folder = ? AND d.org_id = ?` params = append(params, dialect.BooleanStr(true)) + params = append(params, query.OrgId) if len(query.Title) > 0 { sql += " AND d.title " + dialect.LikeStr() + " ?" @@ -432,6 +476,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM dashboard_version WHERE dashboard_id = ?", "DELETE FROM dashboard WHERE folder_id = ?", "DELETE FROM annotation WHERE dashboard_id = ?", + "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", } for _, sql := range deletes { @@ -527,9 +572,7 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery params = append(params, query.UserId) params = append(params, dialect.BooleanStr(false)) - x.ShowSQL(true) err := x.Sql(sql, params...).Find(&query.Result) - x.ShowSQL(false) for _, p := range query.Result { p.PermissionName = p.Permission.String() @@ -583,7 +626,7 @@ func GetDashboardsBySlug(query *m.GetDashboardsBySlugQuery) error { return nil } -func GetDashboardUIDById(query *m.GetDashboardUIDByIdQuery) error { +func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error { var rawSql = `SELECT uid, slug from dashboard WHERE Id=?` us := &m.DashboardRef{} diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 9027f74f33a..829182a8195 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -113,6 +113,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error { }) } +// RemoveDashboardAcl removes a specified permission from the dashboard acl func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { return inTransaction(func(sess *DBSession) error { var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?" @@ -125,6 +126,11 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { }) } +// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three +// different places. +// 1) Permissions for the dashboard +// 2) permissions for its parent folder +// 3) if no specific permissions have been set for the dashboard or its parent folder then get the default permissions func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { var err error @@ -141,7 +147,11 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { da.updated, '' as user_login, '' as user_email, - '' as team + '' as team, + '' as title, + '' as slug, + '' as uid,` + + dialect.BooleanStr(false) + ` AS is_folder FROM dashboard_acl as da WHERE da.dashboard_id = -1` query.Result = make([]*m.DashboardAclInfoDTO, 0) @@ -155,6 +165,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { )`, query.DashboardId, query.DashboardId) rawSQL := ` + -- get permissions for the dashboard and its parent folder SELECT da.id, da.org_id, @@ -167,13 +178,18 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { da.updated, u.login AS user_login, u.email AS user_email, - ug.name AS team + ug.name AS team, + d.title, + d.slug, + d.uid, + d.is_folder FROM` + dialect.Quote("dashboard_acl") + ` as da LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id LEFT OUTER JOIN team ug on ug.id = da.team_id + LEFT OUTER JOIN dashboard d on da.dashboard_id = d.id WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? - -- Also include default permission if has_acl = 0 + -- Also include default permissions if folder or dashboard field "has_acl" is false UNION SELECT @@ -188,10 +204,14 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { da.updated, '' as user_login, '' as user_email, - '' as team - FROM dashboard_acl as da, + '' as team, + folder.title, + folder.slug, + folder.uid, + folder.is_folder + FROM dashboard_acl as da, dashboard as dash - LEFT JOIN dashboard folder on dash.folder_id = folder.id + LEFT OUTER JOIN dashboard folder on dash.folder_id = folder.id WHERE dash.id = ? AND ( dash.has_acl = ` + dialect.BooleanStr(false) + ` or diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go index 10a55f9b22b..4818deaae14 100644 --- a/pkg/services/sqlstore/dashboard_folder_test.go +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -219,13 +219,14 @@ func TestDashboardFolderDataAccess(t *testing.T) { folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod") folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod") + insertTestDashboard("folder in another org", 2, 0, true, "prod") adminUser := createUser("admin", "Admin", true) editorUser := createUser("editor", "Editor", false) viewerUser := createUser("viewer", "Viewer", false) Convey("Admin users", func() { - Convey("Should have write access to all dashboard folders", func() { + Convey("Should have write access to all dashboard folders in their org", func() { query := m.GetFoldersForSignedInUserQuery{ OrgId: 1, SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN}, diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go new file mode 100644 index 00000000000..54068334b4b --- /dev/null +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -0,0 +1,66 @@ +package sqlstore + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", GetProvisionedDashboardDataQuery) + bus.AddHandler("sql", SaveProvisionedDashboard) +} + +type DashboardExtras struct { + Id int64 + DashboardId int64 + Key string + Value string +} + +func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error { + return inTransaction(func(sess *DBSession) error { + err := saveDashboard(sess, cmd.DashboardCmd) + + if err != nil { + return err + } + + cmd.Result = cmd.DashboardCmd.Result + if cmd.DashboardProvisioning.Updated.IsZero() { + cmd.DashboardProvisioning.Updated = cmd.Result.Updated + } + + return saveProvionedData(sess, cmd.DashboardProvisioning, cmd.Result) + }) +} + +func saveProvionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error { + result := &models.DashboardProvisioning{} + + exist, err := sess.Where("dashboard_id=?", dashboard.Id).Get(result) + if err != nil { + return err + } + + cmd.Id = result.Id + cmd.DashboardId = dashboard.Id + + if exist { + _, err = sess.ID(result.Id).Update(cmd) + } else { + _, err = sess.Insert(cmd) + } + + return err +} + +func GetProvisionedDashboardDataQuery(cmd *models.GetProvisionedDashboardDataQuery) error { + var result []*models.DashboardProvisioning + + if err := x.Where("name = ?", cmd.Name).Find(&result); err != nil { + return err + } + + cmd.Result = result + return nil +} diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go new file mode 100644 index 00000000000..c42d95e7495 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_provisioning_test.go @@ -0,0 +1,50 @@ +package sqlstore + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardProvisioningTest(t *testing.T) { + Convey("Testing Dashboard provisioning", t, func() { + InitTestDB(t) + + saveDashboardCmd := &models.SaveDashboardCommand{ + OrgId: 1, + FolderId: 0, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dashboard", + }), + } + + Convey("Saving dashboards with extras", func() { + cmd := &models.SaveProvisionedDashboardCommand{ + DashboardCmd: saveDashboardCmd, + DashboardProvisioning: &models.DashboardProvisioning{ + Name: "default", + ExternalId: "/var/grafana.json", + }, + } + + err := SaveProvisionedDashboard(cmd) + So(err, ShouldBeNil) + So(cmd.Result, ShouldNotBeNil) + So(cmd.Result.Id, ShouldNotEqual, 0) + dashId := cmd.Result.Id + + Convey("Can query for provisioned dashboards", func() { + query := &models.GetProvisionedDashboardDataQuery{Name: "default"} + err := GetProvisionedDashboardDataQuery(query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].DashboardId, ShouldEqual, dashId) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index 0a9a97dbe7a..bd769d307eb 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -100,7 +100,7 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldBeNil) }) - Convey("Should return error if no dashboard is updated", func() { + Convey("Should return not found error if no dashboard is found for update", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, Overwrite: true, @@ -112,7 +112,7 @@ func TestDashboardDataAccess(t *testing.T) { } err := SaveDashboard(&cmd) - So(err, ShouldNotBeNil) + So(err, ShouldEqual, m.ErrDashboardNotFound) }) Convey("Should not be able to overwrite dashboard in another org", func() { @@ -130,70 +130,7 @@ func TestDashboardDataAccess(t *testing.T) { } err := SaveDashboard(&cmd) - So(err, ShouldNotBeNil) - }) - - Convey("Should be able to search for dashboard folder", func() { - query := search.FindPersistedDashboardsQuery{ - Title: "1 test dash folder", - OrgId: 1, - SignedInUser: &m.SignedInUser{OrgId: 1}, - } - - err := SearchDashboards(&query) - So(err, ShouldBeNil) - - So(len(query.Result), ShouldEqual, 1) - hit := query.Result[0] - So(hit.Type, ShouldEqual, search.DashHitFolder) - So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) - }) - - 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) - So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug)) - }) - - 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: []int64{2, 3}, - SignedInUser: &m.SignedInUser{OrgId: 1}, - } - - err := SearchDashboards(&query) - So(err, ShouldBeNil) - - So(len(query.Result), ShouldEqual, 2) - - hit := query.Result[0] - So(len(hit.Tags), ShouldEqual, 2) - - hit2 := query.Result[1] - So(len(hit2.Tags), ShouldEqual, 1) - }) - - Convey("DashboardIds that does not exists should not cause errors", func() { - query := search.FindPersistedDashboardsQuery{ - DashboardIds: []int64{1000}, - SignedInUser: &m.SignedInUser{OrgId: 1}, - } - - err := SearchDashboards(&query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 0) - }) + So(err, ShouldEqual, m.ErrDashboardNotFound) }) Convey("Should be able to save dashboards with same name in different folders", func() { @@ -224,9 +161,140 @@ func TestDashboardDataAccess(t *testing.T) { err = SaveDashboard(&secondSaveCmd) So(err, ShouldBeNil) + So(firstSaveCmd.Result.Id, ShouldNotEqual, secondSaveCmd.Result.Id) }) - Convey("Should not be able to save dashboard with same name in the same folder", func() { + Convey("Should be able to overwrite dashboard in same folder using title", func() { + insertTestDashboard("Dash", 1, 0, false, "prod", "webapp") + folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp") + dashInFolder := insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp") + + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + FolderId: folder.Id, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + So(cmd.Result.Id, ShouldEqual, dashInFolder.Id) + So(cmd.Result.Uid, ShouldEqual, dashInFolder.Uid) + }) + + Convey("Should be able to overwrite dashboard in General folder using title", func() { + dashInGeneral := insertTestDashboard("Dash", 1, 0, false, "prod", "webapp") + folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp") + insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp") + + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + FolderId: 0, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + So(cmd.Result.Id, ShouldEqual, dashInGeneral.Id) + So(cmd.Result.Uid, ShouldEqual, dashInGeneral.Uid) + }) + + Convey("Should not be able to overwrite folder with dashboard in general folder using title", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedFolder.Title, + }), + FolderId: 0, + IsFolder: false, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder) + }) + + Convey("Should not be able to overwrite folder with dashboard in folder using title", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedFolder.Title, + }), + FolderId: savedFolder.Id, + IsFolder: false, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder) + }) + + Convey("Should not be able to overwrite folder with dashboard using id", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedFolder.Id, + "title": "new title", + }), + IsFolder: false, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardTypeMismatch) + }) + + Convey("Should not be able to overwrite dashboard with folder using id", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDash.Id, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardTypeMismatch) + }) + + Convey("Should not be able to overwrite folder with dashboard using uid", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedFolder.Uid, + "title": "new title", + }), + IsFolder: false, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardTypeMismatch) + }) + + Convey("Should not be able to overwrite dashboard with folder using uid", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDash.Uid, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardTypeMismatch) + }) + + Convey("Should not be able to save dashboard with same name in the same folder without overwrite", func() { firstSaveCmd := m.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -256,20 +324,49 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists) }) - Convey("Should not be able to save dashboard with same uid", func() { + Convey("Should be able to save and update dashboard using same uid", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ "id": nil, - "title": "test dash 23", "uid": "dsfalkjngailuedt", + "title": "test dash 23", }), } err := SaveDashboard(&cmd) So(err, ShouldBeNil) err = SaveDashboard(&cmd) - So(err, ShouldNotBeNil) + So(err, ShouldBeNil) + }) + + Convey("Should be able to update dashboard using uid", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDash.Uid, + "title": "new title", + }), + FolderId: 0, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + + Convey("Should be able to get updated dashboard by uid", func() { + query := m.GetDashboardQuery{ + Uid: savedDash.Uid, + OrgId: 1, + } + + err := GetDashboard(&query) + So(err, ShouldBeNil) + + So(query.Result.Id, ShouldEqual, savedDash.Id) + So(query.Result.Title, ShouldEqual, "new title") + So(query.Result.FolderId, ShouldEqual, 0) + }) }) Convey("Should be able to update dashboard with the same title and folder id", func() { @@ -305,7 +402,7 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldBeNil) }) - Convey("Should not be able to update using just uid", func() { + Convey("Should be able to update using uid without id and overwrite", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -317,23 +414,6 @@ func TestDashboardDataAccess(t *testing.T) { FolderId: savedDash.FolderId, } - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardWithSameUIDExists) - }) - - Convey("Should be able to update using just uid with overwrite", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "folderId", - "version": savedDash.Version, - "tags": []interface{}{}, - }), - FolderId: savedDash.FolderId, - Overwrite: true, - } - err := SaveDashboard(&cmd) So(err, ShouldBeNil) }) @@ -362,11 +442,11 @@ func TestDashboardDataAccess(t *testing.T) { generateNewUid = util.GenerateShortUid }) - Convey("Should be able to update dashboard and remove folderId", func() { + Convey("Should be able to update dashboard by id and remove folderId", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": 1, + "id": savedDash.Id, "title": "folderId", "tags": []interface{}{}, }), @@ -381,7 +461,7 @@ func TestDashboardDataAccess(t *testing.T) { cmd = m.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": 1, + "id": savedDash.Id, "title": "folderId", "tags": []interface{}{}, }), @@ -393,7 +473,7 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldBeNil) query := m.GetDashboardQuery{ - Slug: cmd.Result.Slug, + Id: savedDash.Id, OrgId: 1, } @@ -428,6 +508,63 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 2) }) + Convey("Should be able to search for dashboard folder", func() { + query := search.FindPersistedDashboardsQuery{ + Title: "1 test dash folder", + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgId: 1}, + } + + err := SearchDashboards(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + hit := query.Result[0] + So(hit.Type, ShouldEqual, search.DashHitFolder) + So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) + So(hit.FolderTitle, ShouldEqual, "") + }) + + Convey("Should be able to search for a dashboard folder's children", func() { + 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) + So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug)) + So(hit.FolderId, ShouldEqual, savedFolder.Id) + So(hit.FolderUid, ShouldEqual, savedFolder.Uid) + So(hit.FolderTitle, ShouldEqual, savedFolder.Title) + So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) + }) + + Convey("Should be able to search for dashboard by dashboard ids", func() { + Convey("should be able to find two dashboards by id", func() { + query := search.FindPersistedDashboardsQuery{ + DashboardIds: []int64{2, 3}, + SignedInUser: &m.SignedInUser{OrgId: 1}, + } + + err := SearchDashboards(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + + hit := query.Result[0] + So(len(hit.Tags), ShouldEqual, 2) + + hit2 := query.Result[1] + So(len(hit2.Tags), ShouldEqual, 1) + }) + }) + Convey("Given two dashboards, one is starred dashboard by user 10, other starred by user 1", func() { starredDash := insertTestDashboard("starred dash", 1, 0, false) StarDashboard(&m.StarDashboardCommand{ diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index e6f0114ab4d..28f5b8eba9d 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -1,6 +1,8 @@ package sqlstore import ( + "os" + "strings" "testing" "github.com/go-xorm/xorm" @@ -11,10 +13,33 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" ) +var ( + dbSqlite = "sqlite" + dbMySql = "mysql" + dbPostgres = "postgres" +) + 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) + selectedDb := dbSqlite + //selectedDb := dbMySql + //selectedDb := dbPostgres + + var x *xorm.Engine + var err error + + // environment variable present for test db? + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + selectedDb = db + } + + switch strings.ToLower(selectedDb) { + case dbMySql: + x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr) + case dbPostgres: + x, err = xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) + default: + x, err = xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr) + } // x.ShowSQL() diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index edca733e174..1c40e241e15 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -167,4 +167,29 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{ Cols: []string{"org_id", "slug"}, Type: UniqueIndex, })) + + mg.AddMigration("Update dashboard title length", NewTableCharsetMigration("dashboard", []*Column{ + {Name: "title", Type: DB_NVarchar, Length: 189, Nullable: false}, + })) + + mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{ + Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex, + })) + + dashboardExtrasTable := Table{ + Name: "dashboard_provisioning", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "dashboard_id", Type: DB_BigInt, Nullable: true}, + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "external_id", Type: DB_Text, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"dashboard_id"}}, + {Cols: []string{"dashboard_id", "name"}, Type: IndexType}, + }, + } + + mg.AddMigration("create dashboard_provisioning", NewAddTableMigration(dashboardExtrasTable)) } diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 59d96c4f8ca..5322dfd4748 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -123,6 +123,31 @@ func TestAccountDataAccess(t *testing.T) { So(query.Result[0].Role, ShouldEqual, "Admin") }) + Convey("Can get organization users with query", func() { + query := m.GetOrgUsersQuery{ + OrgId: ac1.OrgId, + Query: "ac1", + } + err := GetOrgUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Email, ShouldEqual, ac1.Email) + }) + + Convey("Can get organization users with query and limit", func() { + query := m.GetOrgUsersQuery{ + OrgId: ac1.OrgId, + Query: "ac", + Limit: 1, + } + err := GetOrgUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Email, ShouldEqual, ac1.Email) + }) + Convey("Can set using org", func() { cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.Id} err := SetUsingOrg(&cmd) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 2c2a51fd362..0b991c73c55 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -2,6 +2,7 @@ package sqlstore import ( "fmt" + "strings" "time" "github.com/grafana/grafana/pkg/bus" @@ -69,9 +70,30 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error { func GetOrgUsers(query *m.GetOrgUsersQuery) error { query.Result = make([]*m.OrgUserDTO, 0) + sess := x.Table("org_user") sess.Join("INNER", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) - sess.Where("org_user.org_id=?", query.OrgId) + + whereConditions := make([]string, 0) + whereParams := make([]interface{}, 0) + + whereConditions = append(whereConditions, "org_user.org_id = ?") + whereParams = append(whereParams, query.OrgId) + + if query.Query != "" { + queryWithWildcards := "%" + query.Query + "%" + whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)") + whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards) + } + + if len(whereConditions) > 0 { + sess.Where(strings.Join(whereConditions, " AND "), whereParams...) + } + + if query.Limit > 0 { + sess.Limit(query.Limit, 0) + } + sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role", "user.last_seen_at") sess.Asc("user.email", "user.login") diff --git a/pkg/services/sqlstore/playlist.go b/pkg/services/sqlstore/playlist.go index b33c1f54f92..67720cbadb8 100644 --- a/pkg/services/sqlstore/playlist.go +++ b/pkg/services/sqlstore/playlist.go @@ -1,8 +1,6 @@ package sqlstore import ( - "fmt" - "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" ) @@ -25,8 +23,6 @@ func CreatePlaylist(cmd *m.CreatePlaylistCommand) error { _, err := x.Insert(&playlist) - fmt.Printf("%v", playlist.Id) - playlistItems := make([]m.PlaylistItem, 0) for _, item := range cmd.Items { playlistItems = append(playlistItems, m.PlaylistItem{ diff --git a/pkg/services/sqlstore/search_builder.go b/pkg/services/sqlstore/search_builder.go index 480e7fa99e6..627074d5453 100644 --- a/pkg/services/sqlstore/search_builder.go +++ b/pkg/services/sqlstore/search_builder.go @@ -107,6 +107,7 @@ func (sb *SearchBuilder) buildSelect() { dashboard_tag.term, dashboard.is_folder, dashboard.folder_id, + folder.uid as folder_uid, folder.slug as folder_slug, folder.title as folder_title FROM `) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 98bb1a36eb9..ecb34ad927b 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -25,7 +25,7 @@ func init() { func CreateTeam(cmd *m.CreateTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if isNameTaken, err := isTeamNameTaken(cmd.Name, 0, sess); err != nil { + if isNameTaken, err := isTeamNameTaken(cmd.OrgId, cmd.Name, 0, sess); err != nil { return err } else if isNameTaken { return m.ErrTeamNameTaken @@ -50,7 +50,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error { func UpdateTeam(cmd *m.UpdateTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if isNameTaken, err := isTeamNameTaken(cmd.Name, cmd.Id, sess); err != nil { + if isNameTaken, err := isTeamNameTaken(cmd.OrgId, cmd.Name, cmd.Id, sess); err != nil { return err } else if isNameTaken { return m.ErrTeamNameTaken @@ -80,20 +80,20 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { 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 { + if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", cmd.OrgId, 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 = ?", + "DELETE FROM team_member WHERE org_id=? and team_id = ?", + "DELETE FROM team WHERE org_id=? and id = ?", + "DELETE FROM dashboard_acl WHERE org_id=? and team_id = ?", } for _, sql := range deletes { - _, err := sess.Exec(sql, cmd.Id) + _, err := sess.Exec(sql, cmd.OrgId, cmd.Id) if err != nil { return err } @@ -102,9 +102,9 @@ func DeleteTeam(cmd *m.DeleteTeamCommand) error { }) } -func isTeamNameTaken(name string, existingId int64, sess *DBSession) (bool, error) { +func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession) (bool, error) { var team m.Team - exists, err := sess.Where("name=?", name).Get(&team) + exists, err := sess.Where("org_id=? and name=?", orgId, name).Get(&team) if err != nil { return false, nil @@ -128,6 +128,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error { sql.WriteString(`select team.id as id, + team.org_id, team.name as name, team.email as email, (select count(*) from team_member where team_member.team_id = team.id) as member_count @@ -176,7 +177,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error { func GetTeamById(query *m.GetTeamByIdQuery) error { var team m.Team - exists, err := x.Id(query.Id).Get(&team) + exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team) if err != nil { return err } @@ -194,7 +195,7 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { sess := x.Table("team") sess.Join("INNER", "team_member", "team.id=team_member.team_id") - sess.Where("team_member.user_id=?", query.UserId) + sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId) err := sess.Find(&query.Result) if err != nil { @@ -206,13 +207,13 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { 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 { + if res, err := sess.Query("SELECT 1 from team_member WHERE org_id=? and team_id=? and user_id=?", cmd.OrgId, 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 { + if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", cmd.OrgId, cmd.TeamId); err != nil { return err } else if len(res) != 1 { return m.ErrTeamNotFound @@ -233,8 +234,8 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { 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) + var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" + _, err := sess.Exec(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { return err } @@ -247,7 +248,7 @@ 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.Where("team_member.org_id=? and team_member.team_id=?", query.OrgId, 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") diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index dbae4545266..bebe59f4238 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -27,8 +27,9 @@ func TestTeamCommandsAndQueries(t *testing.T) { userIds = append(userIds, userCmd.Result.Id) } - group1 := m.CreateTeamCommand{Name: "group1 name", Email: "test1@test.com"} - group2 := m.CreateTeamCommand{Name: "group2 name", Email: "test2@test.com"} + var testOrgId int64 = 1 + group1 := m.CreateTeamCommand{OrgId: testOrgId, Name: "group1 name", Email: "test1@test.com"} + group2 := m.CreateTeamCommand{OrgId: testOrgId, Name: "group2 name", Email: "test2@test.com"} err := CreateTeam(&group1) So(err, ShouldBeNil) @@ -36,7 +37,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(err, ShouldBeNil) Convey("Should be able to create teams and add users", func() { - query := &m.SearchTeamsQuery{Name: "group1 name", Page: 1, Limit: 10} + query := &m.SearchTeamsQuery{OrgId: testOrgId, Name: "group1 name", Page: 1, Limit: 10} err = SearchTeams(query) So(err, ShouldBeNil) So(query.Page, ShouldEqual, 1) @@ -44,25 +45,27 @@ func TestTeamCommandsAndQueries(t *testing.T) { team1 := query.Result.Teams[0] So(team1.Name, ShouldEqual, "group1 name") So(team1.Email, ShouldEqual, "test1@test.com") + So(team1.OrgId, ShouldEqual, testOrgId) - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team1.Id, UserId: userIds[0]}) So(err, ShouldBeNil) - q1 := &m.GetTeamMembersQuery{TeamId: team1.Id} + q1 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team1.Id} err = GetTeamMembers(q1) So(err, ShouldBeNil) So(q1.Result[0].TeamId, ShouldEqual, team1.Id) So(q1.Result[0].Login, ShouldEqual, "loginuser0") + So(q1.Result[0].OrgId, ShouldEqual, testOrgId) }) Convey("Should be able to search for teams", func() { - query := &m.SearchTeamsQuery{Query: "group", Page: 1} + query := &m.SearchTeamsQuery{OrgId: testOrgId, 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: ""} + query2 := &m.SearchTeamsQuery{OrgId: testOrgId, Query: ""} err = SearchTeams(query2) So(err, ShouldBeNil) So(len(query2.Result.Teams), ShouldEqual, 2) @@ -70,9 +73,9 @@ func TestTeamCommandsAndQueries(t *testing.T) { 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]}) + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[0]}) - query := &m.GetTeamsByUserQuery{UserId: userIds[0]} + query := &m.GetTeamsByUserQuery{OrgId: testOrgId, UserId: userIds[0]} err = GetTeamsByUser(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) @@ -81,7 +84,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { }) Convey("Should be able to remove users from a group", func() { - err = RemoveTeamMember(&m.RemoveTeamMemberCommand{TeamId: group1.Result.Id, UserId: userIds[0]}) + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0]}) So(err, ShouldBeNil) q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id} @@ -92,20 +95,20 @@ func TestTeamCommandsAndQueries(t *testing.T) { 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]}) + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]}) So(err, ShouldBeNil) - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[2]}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[2]}) So(err, ShouldBeNil) - err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, TeamId: groupId}) + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: testOrgId, Permission: m.PERMISSION_EDIT, TeamId: groupId}) - err = DeleteTeam(&m.DeleteTeamCommand{Id: groupId}) + err = DeleteTeam(&m.DeleteTeamCommand{OrgId: testOrgId, Id: groupId}) So(err, ShouldBeNil) - query := &m.GetTeamByIdQuery{Id: groupId} + query := &m.GetTeamByIdQuery{OrgId: testOrgId, Id: groupId} err = GetTeamById(query) So(err, ShouldEqual, m.ErrTeamNotFound) - permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1} + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: testOrgId} err = GetDashboardAclInfoList(permQuery) So(err, ShouldBeNil) diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index 6f00cd2cd9e..c74e8825bc1 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -210,7 +210,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi if err != nil { return nil, fmt.Errorf("Error getting user info: %s", err) } - + data.OrganizationsUrl = s.apiUrl + "/user/orgs" userInfo := &BasicUserInfo{ Name: data.Login, Login: data.Login, diff --git a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx index eaeba48f0a6..eac18a6c69d 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx @@ -23,7 +23,7 @@ describe('AlertRuleList', () => { .format(), evalData: {}, executionError: '', - dashboardUri: 'd/ufkcofof/my-goal', + url: 'd/ufkcofof/my-goal', canEdit: true, }, ]) diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx index 5ce1efd9ee3..6fb6e3b7d8f 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.tsx @@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component { 'fa-pause': !rule.isPaused, }); - let ruleUrl = `${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; + let ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; return (
  • diff --git a/public/app/containers/ManageDashboards/FolderPermissions.tsx b/public/app/containers/ManageDashboards/FolderPermissions.tsx index 1af58b44bcc..9c82db1c18c 100644 --- a/public/app/containers/ManageDashboards/FolderPermissions.tsx +++ b/public/app/containers/ManageDashboards/FolderPermissions.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component } from 'react'; import { inject, observer } from 'mobx-react'; import { toJS } from 'mobx'; import IContainerProps from 'app/containers/IContainerProps'; @@ -8,6 +8,7 @@ import Tooltip from 'app/core/components/Tooltip/Tooltip'; import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo'; import AddPermissions from 'app/core/components/Permissions/AddPermissions'; import SlideDown from 'app/core/components/Animations/SlideDown'; + @inject('nav', 'folder', 'view', 'permissions') @observer export class FolderPermissions extends Component { @@ -17,6 +18,11 @@ export class FolderPermissions extends Component { this.loadStore(); } + componentWillUnmount() { + const { permissions } = this.props; + permissions.hideAddPermissions(); + } + loadStore() { const { nav, folder, view } = this.props; return folder.load(view.routeParams.get('uid') as string).then(res => { @@ -58,7 +64,7 @@ export class FolderPermissions extends Component { - + diff --git a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx index 72ba9ff6a7b..bed3d569bcc 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx @@ -21,19 +21,27 @@ describe('FolderSettings', () => { ); const store = RootStore.create( - {}, + { + view: { + path: 'asd', + query: {}, + routeParams: { + uid: 'uid-str', + }, + }, + }, { backendSrv: backendSrv, } ); wrapper = shallow(); - return wrapper - .dive() + page = wrapper.dive(); + return page .instance() .loadStore() .then(() => { - page = wrapper.dive(); + page.update(); }); }); diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index bc3fb08d822..ace0eb00b07 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -20,11 +20,5 @@ export function registerAngularDirectives() { ['tagOptions', { watchDepth: 'reference' }], ]); react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']); - react2AngularDirective('dashboardPermissions', DashboardPermissions, [ - 'backendSrv', - 'dashboardId', - 'folderTitle', - 'folderSlug', - 'folderId', - ]); + react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']); } diff --git a/public/app/core/components/Permissions/AddPermissions.jest.tsx b/public/app/core/components/Permissions/AddPermissions.jest.tsx index 98a00a8bd7b..48ff20a16aa 100644 --- a/public/app/core/components/Permissions/AddPermissions.jest.tsx +++ b/public/app/core/components/Permissions/AddPermissions.jest.tsx @@ -26,7 +26,7 @@ describe('AddPermissions', () => { } ); - wrapper = shallow(); + wrapper = shallow(); instance = wrapper.instance(); return store.permissions.load(1, true, false); }); diff --git a/public/app/core/components/Permissions/AddPermissions.tsx b/public/app/core/components/Permissions/AddPermissions.tsx index 3ce4360ff86..94afa7c1180 100644 --- a/public/app/core/components/Permissions/AddPermissions.tsx +++ b/public/app/core/components/Permissions/AddPermissions.tsx @@ -9,7 +9,6 @@ import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore' export interface IProps { permissions: any; backendSrv: any; - dashboardId: any; } @observer class AddPermissions extends Component { @@ -31,12 +30,6 @@ class AddPermissions extends Component { const { value } = evt.target; const { permissions } = this.props; - // if (value === 'Viewer' || value === 'Editor') { - // // permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId); - // // this.resetNewType(); - // return; - // } - permissions.setNewType(value); } diff --git a/public/app/core/components/Permissions/DashboardPermissions.tsx b/public/app/core/components/Permissions/DashboardPermissions.tsx index a1b86e121bf..12339cc7c34 100644 --- a/public/app/core/components/Permissions/DashboardPermissions.tsx +++ b/public/app/core/components/Permissions/DashboardPermissions.tsx @@ -6,12 +6,11 @@ import Tooltip from 'app/core/components/Tooltip/Tooltip'; import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo'; import AddPermissions from 'app/core/components/Permissions/AddPermissions'; import SlideDown from 'app/core/components/Animations/SlideDown'; +import { FolderInfo } from './FolderInfo'; export interface IProps { dashboardId: number; - folderId: number; - folderTitle: string; - folderSlug: string; + folder?: FolderInfo; backendSrv: any; } @observer @@ -28,8 +27,12 @@ class DashboardPermissions extends Component { this.permissions.toggleAddPermissions(); } + componentWillUnmount() { + this.permissions.hideAddPermissions(); + } + render() { - const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props; + const { dashboardId, folder, backendSrv } = this.props; return (
    @@ -50,13 +53,13 @@ class DashboardPermissions extends Component {
    - + diff --git a/public/app/core/components/Permissions/FolderInfo.ts b/public/app/core/components/Permissions/FolderInfo.ts index 67ebb753df0..d4a6020bb71 100644 --- a/public/app/core/components/Permissions/FolderInfo.ts +++ b/public/app/core/components/Permissions/FolderInfo.ts @@ -1,5 +1,5 @@ export interface FolderInfo { - title: string; id: number; - slug: string; + title: string; + url: string; } diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx index 291ee20e157..3140b8fcc0c 100644 --- a/public/app/core/components/Permissions/PermissionsListItem.tsx +++ b/public/app/core/components/Permissions/PermissionsListItem.tsx @@ -30,7 +30,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde folderInfo && ( Inherited from folder{' '} - + {folderInfo.title} {' '} diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index 5c36505aeaa..77bf6c1fe15 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -31,7 +31,7 @@ class UserPicker extends Component { this.debouncedSearch = debounce(this.search, 300, { leading: true, - trailing: false, + trailing: true, }); } @@ -39,10 +39,10 @@ class UserPicker extends Component { const { toggleLoading, backendSrv } = this.props; toggleLoading(true); - return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => { - const users = result.users.map(user => { + return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => { + const users = result.map(user => { return { - id: user.id, + id: user.userId, label: `${user.login} - ${user.email}`, avatarUrl: user.avatarUrl, login: user.login, diff --git a/public/app/core/components/ScrollBar/ScrollBar.tsx b/public/app/core/components/ScrollBar/ScrollBar.tsx index 49a200b0f3b..7d9e015df94 100644 --- a/public/app/core/components/ScrollBar/ScrollBar.tsx +++ b/public/app/core/components/ScrollBar/ScrollBar.tsx @@ -7,7 +7,6 @@ export interface Props { } export default class ScrollBar extends React.Component { - private container: any; private ps: PerfectScrollbar; @@ -16,7 +15,9 @@ export default class ScrollBar extends React.Component { } componentDidMount() { - this.ps = new PerfectScrollbar(this.container); + this.ps = new PerfectScrollbar(this.container, { + wheelPropagation: true, + }); } componentDidUpdate() { diff --git a/public/app/core/components/form_dropdown/form_dropdown.ts b/public/app/core/components/form_dropdown/form_dropdown.ts index cf9c6b5efe0..7ac55e54cf1 100644 --- a/public/app/core/components/form_dropdown/form_dropdown.ts +++ b/public/app/core/components/form_dropdown/form_dropdown.ts @@ -34,6 +34,7 @@ export class FormDropdownCtrl { lookupText: boolean; placeholder: any; startOpen: any; + debounce: number; /** @ngInject **/ constructor(private $scope, $element, private $sce, private templateSrv, private $q) { @@ -72,6 +73,10 @@ export class FormDropdownCtrl { this.source(this.query, this.process.bind(this)); }; + if (this.debounce) { + typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true }); + } + this.linkElement.keydown(evt => { // trigger typeahead on down arrow or enter key if (evt.keyCode === 40 || evt.keyCode === 13) { @@ -263,6 +268,7 @@ export function formDropdownDirective() { lookupText: '@', placeholder: '@', startOpen: '@', + debounce: '@', }, }; } diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 31b6f5b8096..70a1bda3e8b 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -83,6 +83,10 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop body.toggleClass('sidemenu-hidden'); }); + scope.$watch(() => playlistSrv.isPlaying, function(newValue) { + elem.toggleClass('playlist-active', newValue === true); + }); + // tooltip removal fix // manage page classes var pageClass; diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts index 0676b4dae99..c2f6f213dd3 100644 --- a/public/app/core/components/help/help.ts +++ b/public/app/core/components/help/help.ts @@ -19,7 +19,6 @@ export class HelpCtrl { ], Dashboard: [ { keys: ['mod+s'], description: 'Save dashboard' }, - { keys: ['mod+h'], description: 'Hide row controls' }, { keys: ['d', 'r'], description: 'Refresh all panels' }, { keys: ['d', 's'], description: 'Dashboard settings' }, { keys: ['d', 'v'], description: 'Toggle in-active / view mode' }, diff --git a/public/app/core/components/org_switcher.ts b/public/app/core/components/org_switcher.ts index d6efeb51779..1816e11af49 100644 --- a/public/app/core/components/org_switcher.ts +++ b/public/app/core/components/org_switcher.ts @@ -1,5 +1,6 @@ import coreModule from 'app/core/core_module'; import { contextSrv } from 'app/core/services/context_srv'; +import config from 'app/core/config'; const template = `
    - +
    +
    You have changed folder, please save to view permissions.
    +
    diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts index c538f171fa0..e9d5c6180be 100755 --- a/public/app/features/dashboard/settings/settings.ts +++ b/public/app/features/dashboard/settings/settings.ts @@ -14,6 +14,7 @@ export class SettingsCtrl { canSave: boolean; canDelete: boolean; sections: any[]; + hasUnsavedFolderChange: boolean; /** @ngInject */ constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) { @@ -38,6 +39,7 @@ export class SettingsCtrl { this.$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope); this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 }); + this.$rootScope.onAppEvent('dashboard-saved', this.onPostSave.bind(this), $scope); } buildSectionList() { @@ -135,6 +137,10 @@ export class SettingsCtrl { this.dashboardSrv.saveDashboard(); } + onPostSave() { + this.hasUnsavedFolderChange = false; + } + hideSettings() { var urlParams = this.$location.search(); delete urlParams.editview; @@ -195,7 +201,15 @@ export class SettingsCtrl { onFolderChange(folder) { this.dashboard.meta.folderId = folder.id; this.dashboard.meta.folderTitle = folder.title; - this.dashboard.meta.folderSlug = folder.slug; + this.hasUnsavedFolderChange = true; + } + + getFolder() { + return { + id: this.dashboard.meta.folderId, + title: this.dashboard.meta.folderTitle, + url: this.dashboard.meta.folderUrl, + }; } } diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts index 739485b235d..1cb59ef5bac 100644 --- a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts @@ -19,10 +19,10 @@ describe('DashboardImportCtrl', function() { }; validationSrv = { - validateNewDashboardOrFolderName: jest.fn().mockReturnValue(Promise.resolve()), + validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()), }; - ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {}); + ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}); }); describe('when uploading json', function() { diff --git a/public/app/features/dashboard/validation_srv.ts b/public/app/features/dashboard/validation_srv.ts index 8460f4efa71..817be7ca0e3 100644 --- a/public/app/features/dashboard/validation_srv.ts +++ b/public/app/features/dashboard/validation_srv.ts @@ -1,13 +1,27 @@ import coreModule from 'app/core/core_module'; +const hitTypes = { + FOLDER: 'dash-folder', + DASHBOARD: 'dash-db', +}; + export class ValidationSrv { rootName = 'general'; /** @ngInject */ constructor(private $q, private backendSrv) {} - validateNewDashboardOrFolderName(name) { + validateNewDashboardName(folderId, name) { + return this.validate(folderId, name, 'A dashboard in this folder with the same name already exists'); + } + + validateNewFolderName(name) { + return this.validate(0, name, 'A folder or dashboard in the general folder with the same name already exists'); + } + + private validate(folderId, name, existingErrorMessage) { name = (name || '').trim(); + const nameLowerCased = name.toLowerCase(); if (name.length === 0) { return this.$q.reject({ @@ -16,7 +30,7 @@ export class ValidationSrv { }); } - if (name.toLowerCase() === this.rootName) { + if (folderId === 0 && nameLowerCased === this.rootName) { return this.$q.reject({ type: 'EXISTING', message: 'This is a reserved name and cannot be used for a folder.', @@ -25,12 +39,26 @@ export class ValidationSrv { let deferred = this.$q.defer(); - this.backendSrv.search({ query: name }).then(res => { - for (let hit of res) { - if (name.toLowerCase() === hit.title.toLowerCase()) { + const promises = []; + promises.push(this.backendSrv.search({ type: hitTypes.FOLDER, folderIds: [folderId], query: name })); + promises.push(this.backendSrv.search({ type: hitTypes.DASHBOARD, folderIds: [folderId], query: name })); + + this.$q.all(promises).then(res => { + let hits = []; + + if (res.length > 0 && res[0].length > 0) { + hits = res[0]; + } + + if (res.length > 1 && res[1].length > 0) { + hits = hits.concat(res[1]); + } + + for (let hit of hits) { + if (nameLowerCased === hit.title.toLowerCase()) { deferred.reject({ type: 'EXISTING', - message: 'A folder or dashboard with the same name already exists', + message: existingErrorMessage, }); break; } diff --git a/public/app/features/org/partials/team_details.html b/public/app/features/org/partials/team_details.html index 9f06ebdb017..3fce8b3c720 100644 --- a/public/app/features/org/partials/team_details.html +++ b/public/app/features/org/partials/team_details.html @@ -33,7 +33,7 @@ Old picker --> - +
    diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index 01730e2fede..dec7868a553 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -100,7 +100,9 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) { // update scrollbar after mounting ctrl.events.on('component-did-mount', () => { if (ctrl.__proto__.constructor.scrollable) { - panelScrollbar = new PerfectScrollbar(panelContent[0]); + panelScrollbar = new PerfectScrollbar(panelContent[0], { + wheelPropagation: true, + }); } }); diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index 323a88ddaee..2c7698db08e 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -9,7 +9,7 @@ export class SoloPanelCtrl { $scope.init = function() { contextSrv.sidemenu = false; - appEvents.emit('toggle-sidemenu'); + appEvents.emit('toggle-sidemenu-hidden'); var params = $location.search(); panelId = parseInt(params.panelId); diff --git a/public/app/features/plugins/import_list/import_list.html b/public/app/features/plugins/import_list/import_list.html index ff655f0c33a..fec7ba190ec 100644 --- a/public/app/features/plugins/import_list/import_list.html +++ b/public/app/features/plugins/import_list/import_list.html @@ -6,7 +6,7 @@ - + {{dash.title}} diff --git a/public/app/features/plugins/partials/ds_list.html b/public/app/features/plugins/partials/ds_list.html index b247c59ab66..fd537fc47d4 100644 --- a/public/app/features/plugins/partials/ds_list.html +++ b/public/app/features/plugins/partials/ds_list.html @@ -52,7 +52,7 @@