Bladeren bron

Merge remote-tracking branch 'upstream/master' into prometheus-heatmap

Alexander Zobnin 7 jaren geleden
bovenliggende
commit
fa8403750b
87 gewijzigde bestanden met toevoegingen van 3535 en 943 verwijderingen
  1. 20 0
      CHANGELOG.md
  2. 15 12
      ROADMAP.md
  3. 1 4
      conf/defaults.ini
  4. 0 3
      conf/sample.ini
  5. 25 0
      docker/blocks/prometheus2/docker-compose.yaml
  6. 1 1
      docs/sources/http_api/admin.md
  7. 1 1
      docs/sources/http_api/data_source.md
  8. 4 7
      docs/sources/installation/configuration.md
  9. 3 3
      docs/sources/installation/debian.md
  10. 2 2
      docs/sources/installation/rpm.md
  11. 3 7
      docs/sources/installation/windows.md
  12. 3 3
      package.json
  13. 2 2
      packaging/publish/publish_testing.sh
  14. 22 4
      pkg/api/api.go
  15. 23 6
      pkg/api/dashboard.go
  16. 0 214
      pkg/api/dashboard_acl_test.go
  17. 15 10
      pkg/api/dashboard_permission.go
  18. 210 0
      pkg/api/dashboard_permission_test.go
  19. 35 7
      pkg/api/dashboard_snapshot.go
  20. 97 0
      pkg/api/dashboard_snapshot_test.go
  21. 75 2
      pkg/api/dashboard_test.go
  22. 25 0
      pkg/api/dtos/folder.go
  23. 147 0
      pkg/api/folder.go
  24. 108 0
      pkg/api/folder_permission.go
  25. 242 0
      pkg/api/folder_permission_test.go
  26. 254 0
      pkg/api/folder_test.go
  27. 25 1
      pkg/models/dashboard_acl.go
  28. 7 4
      pkg/models/dashboard_snapshot.go
  29. 1 0
      pkg/models/dashboard_version.go
  30. 3 7
      pkg/models/dashboards.go
  31. 91 0
      pkg/models/folders.go
  32. 2 0
      pkg/services/alerting/notifiers/teams.go
  33. 12 2
      pkg/services/cleanup/cleanup.go
  34. 16 11
      pkg/services/dashboards/dashboard_service.go
  35. 2 51
      pkg/services/dashboards/dashboard_service_test.go
  36. 245 0
      pkg/services/dashboards/folder_service.go
  37. 191 0
      pkg/services/dashboards/folder_service_test.go
  38. 107 5
      pkg/services/guardian/guardian.go
  39. 711 0
      pkg/services/guardian/guardian_test.go
  40. 16 4
      pkg/services/sqlstore/dashboard.go
  41. 23 75
      pkg/services/sqlstore/dashboard_service_integration_test.go
  42. 26 12
      pkg/services/sqlstore/dashboard_snapshot.go
  43. 136 2
      pkg/services/sqlstore/dashboard_snapshot_test.go
  44. 25 0
      pkg/services/sqlstore/dashboard_test.go
  45. 1 3
      pkg/services/sqlstore/dashboard_version.go
  46. 0 2
      pkg/setting/setting.go
  47. 6 5
      pkg/social/github_oauth.go
  48. 1 1
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  49. 7 10
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  50. 17 14
      public/app/containers/ManageDashboards/FolderSettings.tsx
  51. 3 3
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  52. 0 8
      public/app/core/components/Permissions/AddPermissions.tsx
  53. 0 4
      public/app/core/components/help/help.ts
  54. 5 40
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  55. 27 37
      public/app/core/services/backend_srv.ts
  56. 5 26
      public/app/core/services/keybindingSrv.ts
  57. 21 0
      public/app/core/utils/kbn.ts
  58. 2 2
      public/app/features/dashboard/create_folder_ctrl.ts
  59. 39 0
      public/app/features/dashboard/dashboard_ctrl.ts
  60. 28 0
      public/app/features/dashboard/dashboard_model.ts
  61. 1 1
      public/app/features/dashboard/dashboard_srv.ts
  62. 6 4
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  63. 1 1
      public/app/features/dashboard/folder_dashboards_ctrl.ts
  64. 6 6
      public/app/features/dashboard/folder_page_loader.ts
  65. 3 3
      public/app/features/dashboard/folder_picker/folder_picker.ts
  66. 10 18
      public/app/features/dashboard/folder_settings_ctrl.ts
  67. 12 1
      public/app/features/dashboard/save_as_modal.ts
  68. 1 1
      public/app/features/dashboard/specs/dashboard_migration.jest.ts
  69. 4 25
      public/app/features/panel/panel_ctrl.ts
  70. 1 1
      public/app/features/panel/solo_panel_ctrl.ts
  71. 78 2
      public/app/features/templating/specs/template_srv.jest.ts
  72. 19 10
      public/app/features/templating/template_srv.ts
  73. 1 1
      public/app/partials/login.html
  74. 1 0
      public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html
  75. 4 0
      public/app/plugins/datasource/graphite/query_ctrl.ts
  76. 1 1
      public/app/routes/dashboard_loaders.ts
  77. 14 12
      public/app/stores/FolderStore/FolderStore.ts
  78. 12 67
      public/app/stores/PermissionsStore/PermissionsStore.jest.ts
  79. 19 34
      public/app/stores/PermissionsStore/PermissionsStore.ts
  80. 93 129
      public/sass/base/_fonts.scss
  81. 1 1
      public/sass/components/_dashboard_grid.scss
  82. 1 1
      public/sass/components/_scrollbar.scss
  83. 3 0
      public/sass/components/_view_states.scss
  84. 1 0
      public/test/mocks/common.ts
  85. 78 0
      tests/api/folder.test.ts
  86. 17 1
      tests/api/setup.ts
  87. 13 6
      yarn.lock

+ 20 - 0
CHANGELOG.md

@@ -1,3 +1,23 @@
+# 5.0.0-stable (2018-03-01)
+
+### Fixes
+
+- **oauth** Fix Github OAuth not working with private Organizations [#11028](https://github.com/grafana/grafana/pull/11028) [@lostick](https://github.com/lostick)
+- **kiosk** white area over bottom panels in kiosk mode [#11010](https://github.com/grafana/grafana/issues/11010)
+- **alerting** Fix OK state doesn't show up in Microsoft Teams [#11032](https://github.com/grafana/grafana/pull/11032), thx [@manacker](https://github.com/manacker)
+
+# 5.0.0-beta5 (2018-02-26)
+
+### Fixes
+
+- **Orgs** Unable to switch org when too many orgs listed [#10774](https://github.com/grafana/grafana/issues/10774)
+- **Folders** Make it easier/explicit to access/modify folders using the API [#10630](https://github.com/grafana/grafana/issues/10630)
+- **Dashboard** Scrollbar works incorrectly in Grafana 5.0 Beta4 in some cases [#10982](https://github.com/grafana/grafana/issues/10982)
+- **ElasticSearch** Custom aggregation sizes no longer allowed for Elasticsearch [#10124](https://github.com/grafana/grafana/issues/10124)
+- **oauth** Github OAuth with allowed organizations fails to login [#10964](https://github.com/grafana/grafana/issues/10964)
+- **heatmap** Heatmap panel has partially hidden legend [#10793](https://github.com/grafana/grafana/issues/10793)
+- **snapshots** Expired snapshots not being cleaned up [#10996](https://github.com/grafana/grafana/pull/10996)
+
 # 5.0.0-beta4 (2018-02-19)
 # 5.0.0-beta4 (2018-02-19)
 
 
 ### Fixes
 ### Fixes

+ 15 - 12
ROADMAP.md

@@ -1,25 +1,28 @@
-# Roadmap (2017-10-31)
+# Roadmap (2018-02-22)
 
 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 But it will give you an idea of our current vision and plan. 
 But it will give you an idea of our current vision and plan. 
 
 
-### Short term (1-4 months)
+### Short term (1-2 months)
 
 
- - Release Grafana v5
-  - Teams
-  - Dashboard folders
-  - Dashboard & folder permissions (assigned to users or groups)
-  - New Dashboard layout engine
-  - New sidemenu & nav UX
+- v5.1
+  - Crossplatform builds & build speed improvements
+  - Enterprise LDAP
+  - New template interpolation syntax
+  - Provisioning workflow
+  - First login registration view
+  - IFQL Initial support
+  
+### Mid term (2-4 months)
+
+- v5.2
+  - Azure monitor backend rewrite
   - Elasticsearch alerting
   - Elasticsearch alerting
-  - React migration foundation (core components) 
-  - Graphite 1.1 Tags Support
+  - Backend plugins? (alert notifiers, auth)
   
   
 ### Long term (4 - 8 months)
 ### Long term (4 - 8 months)
 
 
-- Backend plugins to support more Auth options, Alerting data sources & notifications
 - Alerting improvements (silence, per series tracking, etc)
 - Alerting improvements (silence, per series tracking, etc)
-- Dashboard as configuration and other automation / provisioning improvements
 - Progress on React migration
 - Progress on React migration
 - Change visualization (panel type) on the fly. 
 - Change visualization (panel type) on the fly. 
 - Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) 
 - Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) 

+ 1 - 4
conf/defaults.ini

@@ -187,9 +187,6 @@ external_snapshot_name = Publish to snapshot.raintank.io
 # remove expired snapshot
 # remove expired snapshot
 snapshot_remove_expired = true
 snapshot_remove_expired = true
 
 
-# remove snapshots after 90 days
-snapshot_TTL_days = 90
-
 #################################### Dashboards ##################
 #################################### Dashboards ##################
 
 
 [dashboards]
 [dashboards]
@@ -251,7 +248,7 @@ enabled = false
 allow_sign_up = true
 allow_sign_up = true
 client_id = some_id
 client_id = some_id
 client_secret = some_secret
 client_secret = some_secret
-scopes = user:email
+scopes = user:email,read:org
 auth_url = https://github.com/login/oauth/authorize
 auth_url = https://github.com/login/oauth/authorize
 token_url = https://github.com/login/oauth/access_token
 token_url = https://github.com/login/oauth/access_token
 api_url = https://api.github.com/user
 api_url = https://api.github.com/user

+ 0 - 3
conf/sample.ini

@@ -175,9 +175,6 @@ log_queries =
 # remove expired snapshot
 # remove expired snapshot
 ;snapshot_remove_expired = true
 ;snapshot_remove_expired = true
 
 
-# remove snapshots after 90 days
-;snapshot_TTL_days = 90
-
 #################################### Dashboards History ##################
 #################################### Dashboards History ##################
 [dashboards]
 [dashboards]
 # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1
 # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1

+ 25 - 0
docker/blocks/prometheus2/docker-compose.yaml

@@ -0,0 +1,25 @@
+  prometheus:
+    build: blocks/prometheus2
+    network_mode: host
+    ports:
+      - "9090:9090"
+
+  node_exporter:
+    image: prom/node-exporter
+    network_mode: host
+    ports:
+      - "9100:9100"
+
+  fake-prometheus-data:
+    image: grafana/fake-data-gen
+    network_mode: host
+    ports:
+      - "9091:9091"
+    environment:
+      FD_DATASOURCE: prom
+
+  alertmanager:
+    image: quay.io/prometheus/alertmanager
+    network_mode: host
+    ports:
+      - "9093:9093"

+ 1 - 1
docs/sources/http_api/admin.md

@@ -61,7 +61,7 @@ Content-Type: application/json
     "client_id":"some_id",
     "client_id":"some_id",
     "client_secret":"************",
     "client_secret":"************",
     "enabled":"false",
     "enabled":"false",
-    "scopes":"user:email",
+    "scopes":"user:email,read:org",
     "team_ids":"",
     "team_ids":"",
     "token_url":"https://github.com/login/oauth/access_token"
     "token_url":"https://github.com/login/oauth/access_token"
   },
   },

+ 1 - 1
docs/sources/http_api/data_source.md

@@ -90,7 +90,7 @@ Content-Type: application/json
 
 
 ## Get a single data source by Name
 ## Get a single data source by Name
 
 
-`GET /api/datasources/:name`
+`GET /api/datasources/name/:name`
 
 
 **Example Request**:
 **Example Request**:
 
 

+ 4 - 7
docs/sources/installation/configuration.md

@@ -354,7 +354,7 @@ enabled = true
 allow_sign_up = true
 allow_sign_up = true
 client_id = YOUR_GITHUB_APP_CLIENT_ID
 client_id = YOUR_GITHUB_APP_CLIENT_ID
 client_secret = YOUR_GITHUB_APP_CLIENT_SECRET
 client_secret = YOUR_GITHUB_APP_CLIENT_SECRET
-scopes = user:email
+scopes = user:email,read:org
 auth_url = https://github.com/login/oauth/authorize
 auth_url = https://github.com/login/oauth/authorize
 token_url = https://github.com/login/oauth/access_token
 token_url = https://github.com/login/oauth/access_token
 api_url = https://api.github.com/user
 api_url = https://api.github.com/user
@@ -387,6 +387,7 @@ scopes = user:email,read:org
 team_ids = 150,300
 team_ids = 150,300
 auth_url = https://github.com/login/oauth/authorize
 auth_url = https://github.com/login/oauth/authorize
 token_url = https://github.com/login/oauth/access_token
 token_url = https://github.com/login/oauth/access_token
+api_url = https://api.github.com/user
 allow_sign_up = true
 allow_sign_up = true
 ```
 ```
 
 
@@ -405,6 +406,7 @@ client_secret = YOUR_GITHUB_APP_CLIENT_SECRET
 scopes = user:email,read:org
 scopes = user:email,read:org
 auth_url = https://github.com/login/oauth/authorize
 auth_url = https://github.com/login/oauth/authorize
 token_url = https://github.com/login/oauth/access_token
 token_url = https://github.com/login/oauth/access_token
+api_url = https://api.github.com/user
 allow_sign_up = true
 allow_sign_up = true
 # space-delimited organization names
 # space-delimited organization names
 allowed_organizations = github google
 allowed_organizations = github google
@@ -795,12 +797,9 @@ Set root url to a Grafana instance where you want to publish external snapshots
 ### external_snapshot_name
 ### external_snapshot_name
 Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io`
 Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io`
 
 
-### remove expired snapshot
+### snapshot_remove_expired
 Enabled to automatically remove expired snapshots
 Enabled to automatically remove expired snapshots
 
 
-### remove snapshots after 90 days
-Time to live for snapshots.
-
 ## [external_image_storage]
 ## [external_image_storage]
 These options control how images should be made public so they can be shared on services like slack.
 These options control how images should be made public so they can be shared on services like slack.
 
 
@@ -878,6 +877,4 @@ Defaults to true. Set to false to disable alerting engine and hide Alerting from
 
 
 ### execute_alerts
 ### execute_alerts
 
 
-### execute_alerts = true
-
 Makes it possible to turn off alert rule execution.
 Makes it possible to turn off alert rule execution.

+ 3 - 3
docs/sources/installation/debian.md

@@ -16,7 +16,7 @@ weight = 1
 Description | Download
 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)
 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-beta4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb)
+Beta for Debian-based Linux | [grafana_5.0.0-beta5_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta5_amd64.deb)
 
 
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 installation.
 installation.
@@ -33,9 +33,9 @@ sudo dpkg -i grafana_4.6.3_amd64.deb
 ## Install Latest Beta
 ## Install Latest Beta
 
 
 ```bash
 ```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb
+wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta5_amd64.deb
 sudo apt-get install -y adduser libfontconfig
 sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_5.0.0-beta4_amd64.deb
+sudo dpkg -i grafana_5.0.0-beta5_amd64.deb
 
 
 ```
 ```
 ## APT Repository
 ## APT Repository

+ 2 - 2
docs/sources/installation/rpm.md

@@ -16,7 +16,7 @@ weight = 2
 Description | Download
 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)
 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-beta4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm)
+Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta5 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.x86_64.rpm)
 
 
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 installation.
 installation.
@@ -32,7 +32,7 @@ $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/g
 ## Install Beta
 ## Install Beta
 
 
 ```bash
 ```bash
-$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm
+$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.x86_64.rpm
 ```
 ```
 
 
 Or install manually using `rpm`.
 Or install manually using `rpm`.

+ 3 - 7
docs/sources/installation/windows.md

@@ -14,7 +14,7 @@ weight = 3
 Description | Download
 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 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-beta4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.windows-x64.zip)
+Latest beta package for Windows | [grafana.5.0.0-beta5.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip)
 
 
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 installation.
 installation.
@@ -31,9 +31,9 @@ on windows. Edit `custom.ini` and uncomment the `http_port`
 configuration option (`;` is the comment character in ini files) and change it to something like `8080` or similar.
 configuration option (`;` is the comment character in ini files) and change it to something like `8080` or similar.
 That port should not require extra Windows privileges.
 That port should not require extra Windows privileges.
 
 
-Start Grafana by executing `grafana-server.exe`, preferably from the
+Start Grafana by executing `grafana-server.exe`, located in the `bin` directory, preferably from the
 command line. If you want to run Grafana as windows service, download
 command line. If you want to run Grafana as windows service, download
-[NSSM](https://nssm.cc/). It is very easy add Grafana as a Windows
+[NSSM](https://nssm.cc/). It is very easy to add Grafana as a Windows
 service using that tool.
 service using that tool.
 
 
 Read more about the [configuration options]({{< relref "configuration.md" >}}).
 Read more about the [configuration options]({{< relref "configuration.md" >}}).
@@ -43,7 +43,3 @@ Read more about the [configuration options]({{< relref "configuration.md" >}}).
 The Grafana backend includes Sqlite3 which requires GCC to compile. So
 The Grafana backend includes Sqlite3 which requires GCC to compile. So
 in order to compile Grafana on Windows you need to install GCC. We
 in order to compile Grafana on Windows you need to install GCC. We
 recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download).
 recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download).
-
-Copy `conf/sample.ini` to a file named `conf/custom.ini` and change the
-web server port to something like 8080. The default Grafana port, 3000,
-requires special privileges on Windows.

+ 3 - 3
package.json

@@ -4,7 +4,7 @@
     "company": "Grafana Labs"
     "company": "Grafana Labs"
   },
   },
   "name": "grafana",
   "name": "grafana",
-  "version": "5.0.0-beta4",
+  "version": "5.0.1-pre1",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"
     "url": "http://github.com/grafana/grafana.git"
@@ -155,7 +155,7 @@
     "prop-types": "^15.6.0",
     "prop-types": "^15.6.0",
     "react": "^16.2.0",
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
     "react-dom": "^16.2.0",
-    "react-grid-layout": "^0.16.2",
+    "react-grid-layout-grafana": "0.16.0",
     "react-highlight-words": "^0.10.0",
     "react-highlight-words": "^0.10.0",
     "react-popper": "^0.7.5",
     "react-popper": "^0.7.5",
     "react-select": "^1.1.0",
     "react-select": "^1.1.0",
@@ -165,7 +165,7 @@
     "rst2html": "github:thoward/rst2html#990cb89",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",
     "rxjs": "^5.4.3",
     "tether": "^1.4.0",
     "tether": "^1.4.0",
-    "tether-drop": "https://github.com/torkelo/drop",
+    "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "tinycolor2": "^1.4.1"
     "tinycolor2": "^1.4.1"
   }
   }
 }
 }

+ 2 - 2
packaging/publish/publish_testing.sh

@@ -1,6 +1,6 @@
 #! /usr/bin/env bash
 #! /usr/bin/env bash
-deb_ver=5.0.0-beta4
-rpm_ver=5.0.0-beta4
+deb_ver=5.0.0-beta5
+rpm_ver=5.0.0-beta5
 
 
 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb
 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb
 
 

+ 22 - 4
pkg/api/api.go

@@ -106,7 +106,7 @@ func (hs *HttpServer) registerRoutes() {
 	r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
 	r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
 	r.Get("/api/snapshot/shared-options/", GetSharingOptions)
 	r.Get("/api/snapshot/shared-options/", GetSharingOptions)
 	r.Get("/api/snapshots/:key", GetDashboardSnapshot)
 	r.Get("/api/snapshots/:key", GetDashboardSnapshot)
-	r.Get("/api/snapshots-delete/:key", reqEditorRole, DeleteDashboardSnapshot)
+	r.Get("/api/snapshots-delete/:key", reqEditorRole, wrap(DeleteDashboardSnapshot))
 
 
 	// api renew session based on remember cookie
 	// api renew session based on remember cookie
 	r.Get("/api/login/ping", quota("session"), LoginApiPing)
 	r.Get("/api/login/ping", quota("session"), LoginApiPing)
@@ -246,6 +246,24 @@ func (hs *HttpServer) registerRoutes() {
 		apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
 		apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
 		apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
 		apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
 
 
+		// Folders
+		apiRoute.Group("/folders", func(folderRoute RouteRegister) {
+			folderRoute.Get("/", wrap(GetFolders))
+			folderRoute.Get("/id/:id", wrap(GetFolderById))
+			folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
+
+			folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) {
+				folderUidRoute.Get("/", wrap(GetFolderByUid))
+				folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
+				folderUidRoute.Delete("/", wrap(DeleteFolder))
+
+				folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) {
+					folderPermissionRoute.Get("/", wrap(GetFolderPermissionList))
+					folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions))
+				})
+			})
+		})
+
 		// Dashboard
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
 			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
@@ -266,9 +284,9 @@ func (hs *HttpServer) registerRoutes() {
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
 				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
 				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
 
 
-				dashIdRoute.Group("/acl", func(aclRoute RouteRegister) {
-					aclRoute.Get("/", wrap(GetDashboardAclList))
-					aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl))
+				dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) {
+					dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList))
+					dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions))
 				})
 				})
 			})
 			})
 		})
 		})

+ 23 - 6
pkg/api/dashboard.go

@@ -137,6 +137,7 @@ func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dash
 	if err := bus.Dispatch(&query); err != nil {
 	if err := bus.Dispatch(&query); err != nil {
 		return nil, ApiError(404, "Dashboard not found", err)
 		return nil, ApiError(404, "Dashboard not found", err)
 	}
 	}
+
 	return query.Result, nil
 	return query.Result, nil
 }
 }
 
 
@@ -166,8 +167,10 @@ func DeleteDashboard(c *middleware.Context) Response {
 		return ApiError(500, "Failed to delete dashboard", err)
 		return ApiError(500, "Failed to delete dashboard", err)
 	}
 	}
 
 
-	var resp = map[string]interface{}{"title": dash.Title}
-	return Json(200, resp)
+	return Json(200, util.DynMap{
+		"title":   dash.Title,
+		"message": fmt.Sprintf("Dashboard %s deleted", dash.Title),
+	})
 }
 }
 
 
 func DeleteDashboardByUid(c *middleware.Context) Response {
 func DeleteDashboardByUid(c *middleware.Context) Response {
@@ -186,8 +189,10 @@ func DeleteDashboardByUid(c *middleware.Context) Response {
 		return ApiError(500, "Failed to delete dashboard", err)
 		return ApiError(500, "Failed to delete dashboard", err)
 	}
 	}
 
 
-	var resp = map[string]interface{}{"title": dash.Title}
-	return Json(200, resp)
+	return Json(200, util.DynMap{
+		"title":   dash.Title,
+		"message": fmt.Sprintf("Dashboard %s deleted", dash.Title),
+	})
 }
 }
 
 
 func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
@@ -406,6 +411,18 @@ func GetDashboardVersion(c *middleware.Context) Response {
 // POST /api/dashboards/calculate-diff performs diffs on two dashboards
 // POST /api/dashboards/calculate-diff performs diffs on two dashboards
 func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response {
 func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response {
 
 
+	guardianBase := guardian.New(apiOptions.Base.DashboardId, c.OrgId, c.SignedInUser)
+	if canSave, err := guardianBase.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
+	if apiOptions.Base.DashboardId != apiOptions.New.DashboardId {
+		guardianNew := guardian.New(apiOptions.New.DashboardId, c.OrgId, c.SignedInUser)
+		if canSave, err := guardianNew.CanSave(); err != nil || !canSave {
+			return dashboardGuardianResponse(err)
+		}
+	}
+
 	options := dashdiffs.Options{
 	options := dashdiffs.Options{
 		OrgId:    c.OrgId,
 		OrgId:    c.OrgId,
 		DiffType: dashdiffs.ParseDiffType(apiOptions.DiffType),
 		DiffType: dashdiffs.ParseDiffType(apiOptions.DiffType),
@@ -431,9 +448,9 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff
 
 
 	if options.DiffType == dashdiffs.DiffDelta {
 	if options.DiffType == dashdiffs.DiffDelta {
 		return Respond(200, result.Delta).Header("Content-Type", "application/json")
 		return Respond(200, result.Delta).Header("Content-Type", "application/json")
-	} else {
-		return Respond(200, result.Delta).Header("Content-Type", "text/html")
 	}
 	}
+
+	return Respond(200, result.Delta).Header("Content-Type", "text/html")
 }
 }
 
 
 // RestoreDashboardVersion restores a dashboard to the given version.
 // RestoreDashboardVersion restores a dashboard to the given version.

+ 0 - 214
pkg/api/dashboard_acl_test.go

@@ -1,214 +0,0 @@
-package api
-
-import (
-	"testing"
-
-	"github.com/grafana/grafana/pkg/api/dtos"
-	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/components/simplejson"
-	"github.com/grafana/grafana/pkg/middleware"
-	m "github.com/grafana/grafana/pkg/models"
-
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestDashboardAclApiEndpoint(t *testing.T) {
-	Convey("Given a dashboard acl", t, func() {
-		mockResult := []*m.DashboardAclInfoDTO{
-			{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
-			{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
-			{OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
-			{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
-			{OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
-		}
-		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
-		})
-
-		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
-			query.Result = mockResult
-			return nil
-		})
-
-		teamResp := []*m.Team{}
-		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = teamResp
-			return nil
-		})
-
-		// This tests four scenarios:
-		// 1. user is an org admin
-		// 2. user is an org editor AND has been granted admin permission for the dashboard
-		// 3. user is an org viewer AND has been granted edit permission for the dashboard
-		// 4. user is an org editor AND has no permissions for the dashboard
-
-		Convey("When user is org admin", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
-				Convey("Should be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
-					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
-
-					So(sc.resp.Code, ShouldEqual, 200)
-
-					respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
-					So(err, ShouldBeNil)
-					So(len(respJSON.MustArray()), ShouldEqual, 5)
-					So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
-					So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
-				})
-			})
-
-			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)
-				})
-			})
-		})
-
-		Convey("When user is org editor and has admin permission in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
-				mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
-
-				Convey("Should be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
-					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
-
-					So(sc.resp.Code, ShouldEqual, 200)
-				})
-			})
-
-			Convey("Should not be able to downgrade their own Admin permission", func() {
-				cmd := dtos.UpdateDashboardAclCommand{
-					Items: []dtos.DashboardAclUpdateItem{
-						{UserId: TestUserID, Permission: m.PERMISSION_EDIT},
-					},
-				}
-
-				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
-					mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
-
-					CallPostAcl(sc)
-					So(sc.resp.Code, ShouldEqual, 403)
-				})
-			})
-
-			Convey("Should be able to update permissions", func() {
-				cmd := dtos.UpdateDashboardAclCommand{
-					Items: []dtos.DashboardAclUpdateItem{
-						{UserId: TestUserID, Permission: m.PERMISSION_ADMIN},
-						{UserId: 2, Permission: m.PERMISSION_EDIT},
-					},
-				}
-
-				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
-					mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
-
-					CallPostAcl(sc)
-					So(sc.resp.Code, ShouldEqual, 200)
-				})
-			})
-
-		})
-
-		Convey("When user is org viewer and has edit permission in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) {
-				mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
-
-				// Getting the permissions is an Admin permission
-				Convey("Should not be able to get list of permissions from ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
-					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
-
-					So(sc.resp.Code, ShouldEqual, 403)
-				})
-			})
-		})
-
-		Convey("When user is org editor and not in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
-
-				Convey("Should not be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
-					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
-
-					So(sc.resp.Code, ShouldEqual, 403)
-				})
-			})
-		})
-	})
-}
-
-func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardAclInfoDTO {
-	dtos := make([]*m.DashboardAclInfoDTO, 0)
-
-	for _, acl := range acls {
-		dto := &m.DashboardAclInfoDTO{
-			OrgId:       acl.OrgId,
-			DashboardId: acl.DashboardId,
-			Permission:  acl.Permission,
-			UserId:      acl.UserId,
-			TeamId:      acl.TeamId,
-		}
-		dtos = append(dtos, dto)
-	}
-
-	return dtos
-}
-
-func CallPostAcl(sc *scenarioContext) {
-	bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
-		return nil
-	})
-
-	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
-}
-
-func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
-	Convey(desc+" "+url, func() {
-		defer bus.ClearBusHandlers()
-
-		sc := setupScenarioContext(url)
-
-		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
-			sc.context = c
-			sc.context.UserId = TestUserID
-			sc.context.OrgId = TestOrgID
-			sc.context.OrgRole = role
-
-			return UpdateDashboardAcl(c, cmd)
-		})
-
-		sc.m.Post(routePattern, sc.defaultHandler)
-
-		fn(sc)
-	})
-}

+ 15 - 10
pkg/api/dashboard_acl.go → pkg/api/dashboard_permission.go

@@ -10,7 +10,7 @@ import (
 	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/services/guardian"
 )
 )
 
 
-func GetDashboardAclList(c *middleware.Context) Response {
+func GetDashboardPermissionList(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 
 
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
@@ -18,15 +18,15 @@ func GetDashboardAclList(c *middleware.Context) Response {
 		return rsp
 		return rsp
 	}
 	}
 
 
-	guardian := guardian.New(dashId, c.OrgId, c.SignedInUser)
+	g := guardian.New(dashId, c.OrgId, c.SignedInUser)
 
 
-	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
+	if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin {
 		return dashboardGuardianResponse(err)
 		return dashboardGuardianResponse(err)
 	}
 	}
 
 
-	acl, err := guardian.GetAcl()
+	acl, err := g.GetAcl()
 	if err != nil {
 	if err != nil {
-		return ApiError(500, "Failed to get dashboard acl", err)
+		return ApiError(500, "Failed to get dashboard permissions", err)
 	}
 	}
 
 
 	for _, perm := range acl {
 	for _, perm := range acl {
@@ -38,7 +38,7 @@ func GetDashboardAclList(c *middleware.Context) Response {
 	return Json(200, acl)
 	return Json(200, acl)
 }
 }
 
 
-func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
+func UpdateDashboardPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 
 
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
@@ -46,8 +46,8 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
 		return rsp
 		return rsp
 	}
 	}
 
 
-	guardian := guardian.New(dashId, c.OrgId, c.SignedInUser)
-	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
+	g := guardian.New(dashId, c.OrgId, c.SignedInUser)
+	if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin {
 		return dashboardGuardianResponse(err)
 		return dashboardGuardianResponse(err)
 	}
 	}
 
 
@@ -67,8 +67,13 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
 		})
 		})
 	}
 	}
 
 
-	if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
+	if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
 		if err != nil {
 		if err != nil {
+			if err == guardian.ErrGuardianPermissionExists ||
+				err == guardian.ErrGuardianOverride {
+				return ApiError(400, err.Error(), err)
+			}
+
 			return ApiError(500, "Error while checking dashboard permissions", err)
 			return ApiError(500, "Error while checking dashboard permissions", err)
 		}
 		}
 
 
@@ -82,5 +87,5 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
 		return ApiError(500, "Failed to create permission", err)
 		return ApiError(500, "Failed to create permission", err)
 	}
 	}
 
 
-	return ApiSuccess("Dashboard acl updated")
+	return ApiSuccess("Dashboard permissions updated")
 }
 }

+ 210 - 0
pkg/api/dashboard_permission_test.go

@@ -0,0 +1,210 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDashboardPermissionApiEndpoint(t *testing.T) {
+	Convey("Dashboard permissions test", t, func() {
+		Convey("Given dashboard not exists", func() {
+			bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+				return m.ErrDashboardNotFound
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				callGetDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 404)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 404)
+			})
+		})
+
+		Convey("Given user has no admin permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
+
+			getDashboardQueryResult := m.NewDashboard("Dash")
+			bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+				query.Result = getDashboardQueryResult
+				return nil
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				callGetDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Given user has admin permissions and permissions to update", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+				CanAdminValue:                    true,
+				CheckPermissionBeforeUpdateValue: true,
+				GetAclValue: []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
+					{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
+					{OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
+					{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
+					{OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
+				},
+			})
+
+			getDashboardQueryResult := m.NewDashboard("Dash")
+			bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+				query.Result = getDashboardQueryResult
+				return nil
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
+				callGetDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+				respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+				So(err, ShouldBeNil)
+				So(len(respJSON.MustArray()), ShouldEqual, 5)
+				So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
+				So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("When trying to update permissions with duplicate permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+				CanAdminValue:                    true,
+				CheckPermissionBeforeUpdateValue: false,
+				CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists,
+			})
+
+			getDashboardQueryResult := m.NewDashboard("Dash")
+			bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+				query.Result = getDashboardQueryResult
+				return nil
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 400)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("When trying to override inherited permissions with lower presedence", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+				CanAdminValue:                    true,
+				CheckPermissionBeforeUpdateValue: false,
+				CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride},
+			)
+
+			getDashboardQueryResult := m.NewDashboard("Dash")
+			bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+				query.Result = getDashboardQueryResult
+				return nil
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateDashboardPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 400)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+	})
+}
+
+func callGetDashboardPermissions(sc *scenarioContext) {
+	sc.handlerFunc = GetDashboardPermissionList
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func callUpdateDashboardPermissions(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func updateDashboardPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.OrgId = TestOrgID
+			sc.context.UserId = TestUserID
+
+			return UpdateDashboardPermissions(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 35 - 7
pkg/api/dashboard_snapshot.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
 )
 )
@@ -56,6 +57,7 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho
 	})
 	})
 }
 }
 
 
+// GET /api/snapshots/:key
 func GetDashboardSnapshot(c *middleware.Context) {
 func GetDashboardSnapshot(c *middleware.Context) {
 	key := c.Params(":key")
 	key := c.Params(":key")
 	query := &m.GetDashboardSnapshotQuery{Key: key}
 	query := &m.GetDashboardSnapshotQuery{Key: key}
@@ -90,18 +92,43 @@ func GetDashboardSnapshot(c *middleware.Context) {
 	c.JSON(200, dto)
 	c.JSON(200, dto)
 }
 }
 
 
-func DeleteDashboardSnapshot(c *middleware.Context) {
+// GET /api/snapshots-delete/:key
+func DeleteDashboardSnapshot(c *middleware.Context) Response {
 	key := c.Params(":key")
 	key := c.Params(":key")
+
+	query := &m.GetDashboardSnapshotQuery{DeleteKey: key}
+
+	err := bus.Dispatch(query)
+	if err != nil {
+		return ApiError(500, "Failed to get dashboard snapshot", err)
+	}
+
+	if query.Result == nil {
+		return ApiError(404, "Failed to get dashboard snapshot", nil)
+	}
+	dashboard := query.Result.Dashboard
+	dashboardId := dashboard.Get("id").MustInt64()
+
+	guardian := guardian.New(dashboardId, c.OrgId, c.SignedInUser)
+	canEdit, err := guardian.CanEdit()
+	if err != nil {
+		return ApiError(500, "Error while checking permissions for snapshot", err)
+	}
+
+	if !canEdit && query.Result.UserId != c.SignedInUser.UserId {
+		return ApiError(403, "Access denied to this snapshot", nil)
+	}
+
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: key}
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: key}
 
 
 	if err := bus.Dispatch(cmd); err != nil {
 	if err := bus.Dispatch(cmd); err != nil {
-		c.JsonApiErr(500, "Failed to delete dashboard snapshot", err)
-		return
+		return ApiError(500, "Failed to delete dashboard snapshot", err)
 	}
 	}
 
 
-	c.JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."})
+	return Json(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."})
 }
 }
 
 
+// GET /api/dashboard/snapshots
 func SearchDashboardSnapshots(c *middleware.Context) Response {
 func SearchDashboardSnapshots(c *middleware.Context) Response {
 	query := c.Query("query")
 	query := c.Query("query")
 	limit := c.QueryInt("limit")
 	limit := c.QueryInt("limit")
@@ -111,9 +138,10 @@ func SearchDashboardSnapshots(c *middleware.Context) Response {
 	}
 	}
 
 
 	searchQuery := m.GetDashboardSnapshotsQuery{
 	searchQuery := m.GetDashboardSnapshotsQuery{
-		Name:  query,
-		Limit: limit,
-		OrgId: c.OrgId,
+		Name:         query,
+		Limit:        limit,
+		OrgId:        c.OrgId,
+		SignedInUser: c.SignedInUser,
 	}
 	}
 
 
 	err := bus.Dispatch(&searchQuery)
 	err := bus.Dispatch(&searchQuery)

+ 97 - 0
pkg/api/dashboard_snapshot_test.go

@@ -0,0 +1,97 @@
+package api
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDashboardSnapshotApiEndpoint(t *testing.T) {
+	Convey("Given a single snapshot", t, func() {
+		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
+
+		mockSnapshotResult := &m.DashboardSnapshot{
+			Id:        1,
+			Dashboard: jsonModel,
+			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
+			UserId:    999999,
+		}
+
+		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
+			query.Result = mockSnapshotResult
+			return nil
+		})
+
+		bus.AddHandler("test", func(cmd *m.DeleteDashboardSnapshotCommand) error {
+			return nil
+		})
+
+		viewerRole := m.ROLE_VIEWER
+		editorRole := m.ROLE_EDITOR
+		aclMockResp := []*m.DashboardAclInfoDTO{}
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = aclMockResp
+			return nil
+		})
+
+		teamResp := []*m.Team{}
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = teamResp
+			return nil
+		})
+
+		Convey("When user has editor role and is not in the ACL", func() {
+			Convey("Should not be able to delete snapshot", func() {
+				loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+
+		Convey("When user is editor and dashboard has default ACL", func() {
+			aclMockResp = []*m.DashboardAclInfoDTO{
+				{Role: &viewerRole, Permission: m.PERMISSION_VIEW},
+				{Role: &editorRole, Permission: m.PERMISSION_EDIT},
+			}
+
+			Convey("Should be able to delete a snapshot", func() {
+				loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+					respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+					So(err, ShouldBeNil)
+
+					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
+				})
+			})
+		})
+
+		Convey("When user is editor and is the creator of the snapshot", func() {
+			aclMockResp = []*m.DashboardAclInfoDTO{}
+			mockSnapshotResult.UserId = TestUserID
+
+			Convey("Should be able to delete a snapshot", func() {
+				loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+					respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+					So(err, ShouldBeNil)
+
+					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
+				})
+			})
+		})
+	})
+}

+ 75 - 2
pkg/api/dashboard_test.go

@@ -743,11 +743,57 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			}
 			}
 		})
 		})
 	})
 	})
+
+	Convey("Given two dashboards being compared", t, func() {
+		mockResult := []*m.DashboardAclInfoDTO{}
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = mockResult
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
+			query.Result = &m.DashboardVersion{
+				Data: simplejson.NewFromAny(map[string]interface{}{
+					"title": "Dash" + string(query.DashboardId),
+				}),
+			}
+			return nil
+		})
+
+		cmd := dtos.CalculateDiffOptions{
+			Base: dtos.CalculateDiffTarget{
+				DashboardId: 1,
+				Version:     1,
+			},
+			New: dtos.CalculateDiffTarget{
+				DashboardId: 2,
+				Version:     2,
+			},
+			DiffType: "basic",
+		}
+
+		Convey("when user does not have permission", func() {
+			role := m.ROLE_VIEWER
+
+			postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+		})
+
+		Convey("when user does have permission", func() {
+			role := m.ROLE_ADMIN
+
+			postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+		})
+	})
 }
 }
 
 
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
-	sc.handlerFunc = GetDashboard
-	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+	CallGetDashboard(sc)
 
 
 	So(sc.resp.Code, ShouldEqual, 200)
 	So(sc.resp.Code, ShouldEqual, 200)
 
 
@@ -758,6 +804,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta
 	return dash
 	return dash
 }
 }
 
 
+func CallGetDashboard(sc *scenarioContext) {
+	sc.handlerFunc = GetDashboard
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
 func CallGetDashboardVersion(sc *scenarioContext) {
 func CallGetDashboardVersion(sc *scenarioContext) {
 	bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
 	bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
 		query.Result = &m.DashboardVersion{}
 		query.Result = &m.DashboardVersion{}
@@ -831,6 +882,28 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
 	})
 	})
 }
 }
 
 
+func postDiffScenario(desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, role m.RoleType, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.SignedInUser = &m.SignedInUser{
+				OrgId:  TestOrgID,
+				UserId: TestUserID,
+			}
+			sc.context.OrgRole = role
+
+			return CalculateDashboardDiff(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
 func (sc *scenarioContext) ToJson() *simplejson.Json {
 func (sc *scenarioContext) ToJson() *simplejson.Json {
 	var result *simplejson.Json
 	var result *simplejson.Json
 	err := json.NewDecoder(sc.resp.Body).Decode(&result)
 	err := json.NewDecoder(sc.resp.Body).Decode(&result)

+ 25 - 0
pkg/api/dtos/folder.go

@@ -0,0 +1,25 @@
+package dtos
+
+import "time"
+
+type Folder struct {
+	Id        int64     `json:"id"`
+	Uid       string    `json:"uid"`
+	Title     string    `json:"title"`
+	Url       string    `json:"url"`
+	HasAcl    bool      `json:"hasAcl"`
+	CanSave   bool      `json:"canSave"`
+	CanEdit   bool      `json:"canEdit"`
+	CanAdmin  bool      `json:"canAdmin"`
+	CreatedBy string    `json:"createdBy"`
+	Created   time.Time `json:"created"`
+	UpdatedBy string    `json:"updatedBy"`
+	Updated   time.Time `json:"updated"`
+	Version   int       `json:"version"`
+}
+
+type FolderSearchHit struct {
+	Id    int64  `json:"id"`
+	Uid   string `json:"uid"`
+	Title string `json:"title"`
+}

+ 147 - 0
pkg/api/folder.go

@@ -0,0 +1,147 @@
+package api
+
+import (
+	"fmt"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/services/guardian"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+func GetFolders(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folders, err := s.GetFolders(c.QueryInt("limit"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	result := make([]dtos.FolderSearchHit, 0)
+
+	for _, f := range folders {
+		result = append(result, dtos.FolderSearchHit{
+			Id:    f.Id,
+			Uid:   f.Uid,
+			Title: f.Title,
+		})
+	}
+
+	return Json(200, result)
+}
+
+func GetFolderByUid(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderByUid(c.Params(":uid"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, folder))
+}
+
+func GetFolderById(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderById(c.ParamsInt64(":id"))
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, folder))
+}
+
+func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	err := s.CreateFolder(&cmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, cmd.Result))
+}
+
+func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	err := s.UpdateFolder(c.Params(":uid"), &cmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, cmd.Result))
+}
+
+func DeleteFolder(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	f, err := s.DeleteFolder(c.Params(":uid"))
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	return Json(200, util.DynMap{
+		"title":   f.Title,
+		"message": fmt.Sprintf("Folder %s deleted", f.Title),
+	})
+}
+
+func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder {
+	canEdit, _ := g.CanEdit()
+	canSave, _ := g.CanSave()
+	canAdmin, _ := g.CanAdmin()
+
+	// Finding creator and last updater of the folder
+	updater, creator := "Anonymous", "Anonymous"
+	if folder.CreatedBy > 0 {
+		creator = getUserLogin(folder.CreatedBy)
+	}
+	if folder.UpdatedBy > 0 {
+		updater = getUserLogin(folder.UpdatedBy)
+	}
+
+	return dtos.Folder{
+		Id:        folder.Id,
+		Uid:       folder.Uid,
+		Title:     folder.Title,
+		Url:       folder.Url,
+		HasAcl:    folder.HasAcl,
+		CanSave:   canSave,
+		CanEdit:   canEdit,
+		CanAdmin:  canAdmin,
+		CreatedBy: creator,
+		Created:   folder.Created,
+		UpdatedBy: updater,
+		Updated:   folder.Updated,
+		Version:   folder.Version,
+	}
+}
+
+func toFolderError(err error) Response {
+	if err == m.ErrFolderTitleEmpty ||
+		err == m.ErrFolderSameNameExists ||
+		err == m.ErrFolderWithSameUIDExists ||
+		err == m.ErrDashboardTypeMismatch ||
+		err == m.ErrDashboardInvalidUid ||
+		err == m.ErrDashboardUidToLong {
+		return ApiError(400, err.Error(), nil)
+	}
+
+	if err == m.ErrFolderAccessDenied {
+		return ApiError(403, "Access denied", err)
+	}
+
+	if err == m.ErrFolderNotFound {
+		return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()})
+	}
+
+	if err == m.ErrFolderVersionMismatch {
+		return Json(412, util.DynMap{"status": "version-mismatch", "message": m.ErrFolderVersionMismatch.Error()})
+	}
+
+	return ApiError(500, "Folder API error", err)
+}

+ 108 - 0
pkg/api/folder_permission.go

@@ -0,0 +1,108 @@
+package api
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/services/guardian"
+)
+
+func GetFolderPermissionList(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderByUid(c.Params(":uid"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+
+	if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin {
+		return toFolderError(m.ErrFolderAccessDenied)
+	}
+
+	acl, err := g.GetAcl()
+	if err != nil {
+		return ApiError(500, "Failed to get folder permissions", err)
+	}
+
+	for _, perm := range acl {
+		perm.FolderId = folder.Id
+		perm.DashboardId = 0
+
+		if perm.Slug != "" {
+			perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
+		}
+	}
+
+	return Json(200, acl)
+}
+
+func UpdateFolderPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderByUid(c.Params(":uid"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+	canAdmin, err := g.CanAdmin()
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	if !canAdmin {
+		return toFolderError(m.ErrFolderAccessDenied)
+	}
+
+	cmd := m.UpdateDashboardAclCommand{}
+	cmd.DashboardId = folder.Id
+
+	for _, item := range apiCmd.Items {
+		cmd.Items = append(cmd.Items, &m.DashboardAcl{
+			OrgId:       c.OrgId,
+			DashboardId: folder.Id,
+			UserId:      item.UserId,
+			TeamId:      item.TeamId,
+			Role:        item.Role,
+			Permission:  item.Permission,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+		})
+	}
+
+	if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
+		if err != nil {
+			if err == guardian.ErrGuardianPermissionExists ||
+				err == guardian.ErrGuardianOverride {
+				return ApiError(400, err.Error(), err)
+			}
+
+			return ApiError(500, "Error while checking folder permissions", err)
+		}
+
+		return ApiError(403, "Cannot remove own admin permission for a folder", nil)
+	}
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrDashboardAclInfoMissing {
+			err = m.ErrFolderAclInfoMissing
+		}
+		if err == m.ErrDashboardPermissionDashboardEmpty {
+			err = m.ErrFolderPermissionFolderEmpty
+		}
+
+		if err == m.ErrFolderAclInfoMissing || err == m.ErrFolderPermissionFolderEmpty {
+			return ApiError(409, err.Error(), err)
+		}
+
+		return ApiError(500, "Failed to create permission", err)
+	}
+
+	return ApiSuccess("Folder permissions updated")
+}

+ 242 - 0
pkg/api/folder_permission_test.go

@@ -0,0 +1,242 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/services/guardian"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestFolderPermissionApiEndpoint(t *testing.T) {
+	Convey("Folder permissions test", t, func() {
+		Convey("Given folder not exists", func() {
+			mock := &fakeFolderService{
+				GetFolderByUidError: m.ErrFolderNotFound,
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				callGetFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 404)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 404)
+			})
+
+			Reset(func() {
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+
+		Convey("Given user has no admin permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
+
+			mock := &fakeFolderService{
+				GetFolderByUidResult: &m.Folder{
+					Id:    1,
+					Uid:   "uid",
+					Title: "Folder",
+				},
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				callGetFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+
+		Convey("Given user has admin permissions and permissions to update", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+				CanAdminValue:                    true,
+				CheckPermissionBeforeUpdateValue: true,
+				GetAclValue: []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
+					{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
+					{OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
+					{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
+					{OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
+				},
+			})
+
+			mock := &fakeFolderService{
+				GetFolderByUidResult: &m.Folder{
+					Id:    1,
+					Uid:   "uid",
+					Title: "Folder",
+				},
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
+				callGetFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+				respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+				So(err, ShouldBeNil)
+				So(len(respJSON.MustArray()), ShouldEqual, 5)
+				So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
+				So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+
+		Convey("When trying to update permissions with duplicate permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+				CanAdminValue:                    true,
+				CheckPermissionBeforeUpdateValue: false,
+				CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists,
+			})
+
+			mock := &fakeFolderService{
+				GetFolderByUidResult: &m.Folder{
+					Id:    1,
+					Uid:   "uid",
+					Title: "Folder",
+				},
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 400)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+
+		Convey("When trying to override inherited permissions with lower presedence", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
+				CanAdminValue:                    true,
+				CheckPermissionBeforeUpdateValue: false,
+				CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride},
+			)
+
+			mock := &fakeFolderService{
+				GetFolderByUidResult: &m.Folder{
+					Id:    1,
+					Uid:   "uid",
+					Title: "Folder",
+				},
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 400)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+	})
+}
+
+func callGetFolderPermissions(sc *scenarioContext) {
+	sc.handlerFunc = GetFolderPermissionList
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func callUpdateFolderPermissions(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func updateFolderPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.OrgId = TestOrgID
+			sc.context.UserId = TestUserID
+
+			return UpdateFolderPermissions(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 254 - 0
pkg/api/folder_test.go

@@ -0,0 +1,254 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+
+	m "github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestFoldersApiEndpoint(t *testing.T) {
+	Convey("Create/update folder response tests", t, func() {
+		Convey("Given a correct request for creating a folder", func() {
+			cmd := m.CreateFolderCommand{
+				Uid:   "uid",
+				Title: "Folder",
+			}
+
+			mock := &fakeFolderService{
+				CreateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder"},
+			}
+
+			createFolderScenario("When calling POST on", "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) {
+				callCreateFolder(sc)
+
+				Convey("It should return correct response data", func() {
+					folder := dtos.Folder{}
+					err := json.NewDecoder(sc.resp.Body).Decode(&folder)
+					So(err, ShouldBeNil)
+					So(folder.Id, ShouldEqual, 1)
+					So(folder.Uid, ShouldEqual, "uid")
+					So(folder.Title, ShouldEqual, "Folder")
+				})
+			})
+		})
+
+		Convey("Given incorrect requests for creating a folder", func() {
+			testCases := []struct {
+				Error              error
+				ExpectedStatusCode int
+			}{
+				{Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403},
+				{Error: m.ErrFolderNotFound, ExpectedStatusCode: 404},
+				{Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412},
+				{Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500},
+			}
+
+			cmd := m.CreateFolderCommand{
+				Uid:   "uid",
+				Title: "Folder",
+			}
+
+			for _, tc := range testCases {
+				mock := &fakeFolderService{
+					CreateFolderError: tc.Error,
+				}
+
+				createFolderScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()), "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) {
+					callCreateFolder(sc)
+					if sc.resp.Code != tc.ExpectedStatusCode {
+						t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code)
+					}
+				})
+			}
+		})
+
+		Convey("Given a correct request for updating a folder", func() {
+			cmd := m.UpdateFolderCommand{
+				Title: "Folder upd",
+			}
+
+			mock := &fakeFolderService{
+				UpdateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder upd"},
+			}
+
+			updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) {
+				callUpdateFolder(sc)
+
+				Convey("It should return correct response data", func() {
+					folder := dtos.Folder{}
+					err := json.NewDecoder(sc.resp.Body).Decode(&folder)
+					So(err, ShouldBeNil)
+					So(folder.Id, ShouldEqual, 1)
+					So(folder.Uid, ShouldEqual, "uid")
+					So(folder.Title, ShouldEqual, "Folder upd")
+				})
+			})
+		})
+
+		Convey("Given incorrect requests for updating a folder", func() {
+			testCases := []struct {
+				Error              error
+				ExpectedStatusCode int
+			}{
+				{Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403},
+				{Error: m.ErrFolderNotFound, ExpectedStatusCode: 404},
+				{Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412},
+				{Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500},
+			}
+
+			cmd := m.UpdateFolderCommand{
+				Title: "Folder upd",
+			}
+
+			for _, tc := range testCases {
+				mock := &fakeFolderService{
+					UpdateFolderError: tc.Error,
+				}
+
+				updateFolderScenario(fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()), "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) {
+					callUpdateFolder(sc)
+					if sc.resp.Code != tc.ExpectedStatusCode {
+						t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code)
+					}
+				})
+			}
+		})
+	})
+}
+
+func callGetFolderByUid(sc *scenarioContext) {
+	sc.handlerFunc = GetFolderByUid
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func callDeleteFolder(sc *scenarioContext) {
+	sc.handlerFunc = DeleteFolder
+	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+}
+
+func callCreateFolder(sc *scenarioContext) {
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func createFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.CreateFolderCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
+
+			return CreateFolder(c, cmd)
+		})
+
+		origNewFolderService := dashboards.NewFolderService
+		mockFolderService(mock)
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		defer func() {
+			dashboards.NewFolderService = origNewFolderService
+		}()
+
+		fn(sc)
+	})
+}
+
+func callUpdateFolder(sc *scenarioContext) {
+	sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
+}
+
+func updateFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.UpdateFolderCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
+
+			return UpdateFolder(c, cmd)
+		})
+
+		origNewFolderService := dashboards.NewFolderService
+		mockFolderService(mock)
+
+		sc.m.Put(routePattern, sc.defaultHandler)
+
+		defer func() {
+			dashboards.NewFolderService = origNewFolderService
+		}()
+
+		fn(sc)
+	})
+}
+
+type fakeFolderService struct {
+	GetFoldersResult     []*models.Folder
+	GetFoldersError      error
+	GetFolderByUidResult *models.Folder
+	GetFolderByUidError  error
+	GetFolderByIdResult  *models.Folder
+	GetFolderByIdError   error
+	CreateFolderResult   *models.Folder
+	CreateFolderError    error
+	UpdateFolderResult   *models.Folder
+	UpdateFolderError    error
+	DeleteFolderResult   *models.Folder
+	DeleteFolderError    error
+	DeletedFolderUids    []string
+}
+
+func (s *fakeFolderService) GetFolders(limit int) ([]*models.Folder, error) {
+	return s.GetFoldersResult, s.GetFoldersError
+}
+
+func (s *fakeFolderService) GetFolderById(id int64) (*models.Folder, error) {
+	return s.GetFolderByIdResult, s.GetFolderByIdError
+}
+
+func (s *fakeFolderService) GetFolderByUid(uid string) (*models.Folder, error) {
+	return s.GetFolderByUidResult, s.GetFolderByUidError
+}
+
+func (s *fakeFolderService) CreateFolder(cmd *models.CreateFolderCommand) error {
+	cmd.Result = s.CreateFolderResult
+	return s.CreateFolderError
+}
+
+func (s *fakeFolderService) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error {
+	cmd.Result = s.UpdateFolderResult
+	return s.UpdateFolderError
+}
+
+func (s *fakeFolderService) DeleteFolder(uid string) (*models.Folder, error) {
+	s.DeletedFolderUids = append(s.DeletedFolderUids, uid)
+	return s.DeleteFolderResult, s.DeleteFolderError
+}
+
+func mockFolderService(mock *fakeFolderService) {
+	dashboards.NewFolderService = func(orgId int64, user *models.SignedInUser) dashboards.FolderService {
+		return mock
+	}
+}

+ 25 - 1
pkg/models/dashboard_acl.go

@@ -26,6 +26,8 @@ func (p PermissionType) String() string {
 var (
 var (
 	ErrDashboardAclInfoMissing           = errors.New("User id and team id cannot both be empty for a dashboard permission.")
 	ErrDashboardAclInfoMissing           = errors.New("User id and team id cannot both be empty for a dashboard permission.")
 	ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.")
 	ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.")
+	ErrFolderAclInfoMissing              = errors.New("User id and team id cannot both be empty for a folder permission.")
+	ErrFolderPermissionFolderEmpty       = errors.New("Folder Id must be greater than zero for a folder permission.")
 )
 )
 
 
 // Dashboard ACL model
 // Dashboard ACL model
@@ -45,7 +47,8 @@ type DashboardAcl struct {
 
 
 type DashboardAclInfoDTO struct {
 type DashboardAclInfoDTO struct {
 	OrgId       int64 `json:"-"`
 	OrgId       int64 `json:"-"`
-	DashboardId int64 `json:"dashboardId"`
+	DashboardId int64 `json:"dashboardId,omitempty"`
+	FolderId    int64 `json:"folderId,omitempty"`
 
 
 	Created time.Time `json:"created"`
 	Created time.Time `json:"created"`
 	Updated time.Time `json:"updated"`
 	Updated time.Time `json:"updated"`
@@ -65,6 +68,27 @@ type DashboardAclInfoDTO struct {
 	Url            string         `json:"url"`
 	Url            string         `json:"url"`
 }
 }
 
 
+func (dto *DashboardAclInfoDTO) hasSameRoleAs(other *DashboardAclInfoDTO) bool {
+	if dto.Role == nil || other.Role == nil {
+		return false
+	}
+
+	return dto.UserId <= 0 && dto.TeamId <= 0 && dto.UserId == other.UserId && dto.TeamId == other.TeamId && *dto.Role == *other.Role
+}
+
+func (dto *DashboardAclInfoDTO) hasSameUserAs(other *DashboardAclInfoDTO) bool {
+	return dto.UserId > 0 && dto.UserId == other.UserId
+}
+
+func (dto *DashboardAclInfoDTO) hasSameTeamAs(other *DashboardAclInfoDTO) bool {
+	return dto.TeamId > 0 && dto.TeamId == other.TeamId
+}
+
+// IsDuplicateOf returns true if other item has same role, same user or same team
+func (dto *DashboardAclInfoDTO) IsDuplicateOf(other *DashboardAclInfoDTO) bool {
+	return dto.hasSameRoleAs(other) || dto.hasSameUserAs(other) || dto.hasSameTeamAs(other)
+}
+
 //
 //
 // COMMANDS
 // COMMANDS
 //
 //

+ 7 - 4
pkg/models/dashboard_snapshot.go

@@ -64,10 +64,12 @@ type DeleteDashboardSnapshotCommand struct {
 }
 }
 
 
 type DeleteExpiredSnapshotsCommand struct {
 type DeleteExpiredSnapshotsCommand struct {
+	DeletedRows int64
 }
 }
 
 
 type GetDashboardSnapshotQuery struct {
 type GetDashboardSnapshotQuery struct {
-	Key string
+	Key       string
+	DeleteKey string
 
 
 	Result *DashboardSnapshot
 	Result *DashboardSnapshot
 }
 }
@@ -76,9 +78,10 @@ type DashboardSnapshots []*DashboardSnapshot
 type DashboardSnapshotsList []*DashboardSnapshotDTO
 type DashboardSnapshotsList []*DashboardSnapshotDTO
 
 
 type GetDashboardSnapshotsQuery struct {
 type GetDashboardSnapshotsQuery struct {
-	Name  string
-	Limit int
-	OrgId int64
+	Name         string
+	Limit        int
+	OrgId        int64
+	SignedInUser *SignedInUser
 
 
 	Result DashboardSnapshotsList
 	Result DashboardSnapshotsList
 }
 }

+ 1 - 0
pkg/models/dashboard_version.go

@@ -75,4 +75,5 @@ type GetDashboardVersionsQuery struct {
 //
 //
 
 
 type DeleteExpiredVersionsCommand struct {
 type DeleteExpiredVersionsCommand struct {
+	DeletedRows int64
 }
 }

+ 3 - 7
pkg/models/dashboards.go

@@ -14,7 +14,7 @@ import (
 // Typed errors
 // Typed errors
 var (
 var (
 	ErrDashboardNotFound                      = errors.New("Dashboard not found")
 	ErrDashboardNotFound                      = errors.New("Dashboard not found")
-	ErrFolderNotFound                         = errors.New("Folder not found")
+	ErrDashboardFolderNotFound                = errors.New("Folder not found")
 	ErrDashboardSnapshotNotFound              = errors.New("Dashboard snapshot not found")
 	ErrDashboardSnapshotNotFound              = errors.New("Dashboard snapshot not found")
 	ErrDashboardWithSameUIDExists             = errors.New("A dashboard with the same uid already exists")
 	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")
 	ErrDashboardWithSameNameInFolderExists    = errors.New("A dashboard with the same name in the folder already exists")
@@ -112,9 +112,9 @@ func NewDashboard(title string) *Dashboard {
 // NewDashboardFolder creates a new dashboard folder
 // NewDashboardFolder creates a new dashboard folder
 func NewDashboardFolder(title string) *Dashboard {
 func NewDashboardFolder(title string) *Dashboard {
 	folder := NewDashboard(title)
 	folder := NewDashboard(title)
+	folder.IsFolder = true
 	folder.Data.Set("schemaVersion", 16)
 	folder.Data.Set("schemaVersion", 16)
-	folder.Data.Set("editable", true)
-	folder.Data.Set("hideControls", true)
+	folder.Data.Set("version", 0)
 	folder.IsFolder = true
 	folder.IsFolder = true
 	return folder
 	return folder
 }
 }
@@ -166,10 +166,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
 		userId = -1
 		userId = -1
 	}
 	}
 
 
-	if dash.Data.Get("version").MustInt(0) == 0 {
-		dash.CreatedBy = userId
-	}
-
 	dash.UpdatedBy = userId
 	dash.UpdatedBy = userId
 	dash.OrgId = cmd.OrgId
 	dash.OrgId = cmd.OrgId
 	dash.PluginId = cmd.PluginId
 	dash.PluginId = cmd.PluginId

+ 91 - 0
pkg/models/folders.go

@@ -0,0 +1,91 @@
+package models
+
+import (
+	"errors"
+	"strings"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrFolderNotFound                = errors.New("Folder not found")
+	ErrFolderVersionMismatch         = errors.New("The folder has been changed by someone else")
+	ErrFolderTitleEmpty              = errors.New("Folder title cannot be empty")
+	ErrFolderWithSameUIDExists       = errors.New("A folder/dashboard with the same uid already exists")
+	ErrFolderSameNameExists          = errors.New("A folder or dashboard in the general folder with the same name already exists")
+	ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id")
+	ErrFolderAccessDenied            = errors.New("Access denied to folder")
+)
+
+type Folder struct {
+	Id      int64
+	Uid     string
+	Title   string
+	Url     string
+	Version int
+
+	Created time.Time
+	Updated time.Time
+
+	UpdatedBy int64
+	CreatedBy int64
+	HasAcl    bool
+}
+
+// GetDashboardModel turns the command into the savable model
+func (cmd *CreateFolderCommand) GetDashboardModel(orgId int64, userId int64) *Dashboard {
+	dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title))
+	dashFolder.OrgId = orgId
+	dashFolder.SetUid(strings.TrimSpace(cmd.Uid))
+
+	if userId == 0 {
+		userId = -1
+	}
+
+	dashFolder.CreatedBy = userId
+	dashFolder.UpdatedBy = userId
+	dashFolder.UpdateSlug()
+
+	return dashFolder
+}
+
+// UpdateDashboardModel updates an existing model from command into model for update
+func (cmd *UpdateFolderCommand) UpdateDashboardModel(dashFolder *Dashboard, orgId int64, userId int64) {
+	dashFolder.OrgId = orgId
+	dashFolder.Title = strings.TrimSpace(cmd.Title)
+	dashFolder.Data.Set("title", dashFolder.Title)
+
+	if cmd.Uid != "" {
+		dashFolder.SetUid(cmd.Uid)
+	}
+
+	dashFolder.SetVersion(cmd.Version)
+	dashFolder.IsFolder = true
+
+	if userId == 0 {
+		userId = -1
+	}
+
+	dashFolder.UpdatedBy = userId
+	dashFolder.UpdateSlug()
+}
+
+//
+// COMMANDS
+//
+
+type CreateFolderCommand struct {
+	Uid   string `json:"uid"`
+	Title string `json:"title"`
+
+	Result *Folder
+}
+
+type UpdateFolderCommand struct {
+	Uid       string `json:"uid"`
+	Title     string `json:"title"`
+	Version   int    `json:"version"`
+	Overwrite bool   `json:"overwrite"`
+
+	Result *Folder
+}

+ 2 - 0
pkg/services/alerting/notifiers/teams.go

@@ -82,6 +82,8 @@ func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error {
 	message := this.Mention
 	message := this.Mention
 	if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok.
 	if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok.
 		message += " " + evalContext.Rule.Message
 		message += " " + evalContext.Rule.Message
+	} else {
+		message += " " // summary must not be empty
 	}
 	}
 
 
 	body := map[string]interface{}{
 	body := map[string]interface{}{

+ 12 - 2
pkg/services/cleanup/cleanup.go

@@ -83,11 +83,21 @@ func (service *CleanUpService) cleanUpTmpFiles() {
 }
 }
 
 
 func (service *CleanUpService) deleteExpiredSnapshots() {
 func (service *CleanUpService) deleteExpiredSnapshots() {
-	bus.Dispatch(&m.DeleteExpiredSnapshotsCommand{})
+	cmd := m.DeleteExpiredSnapshotsCommand{}
+	if err := bus.Dispatch(&cmd); err != nil {
+		service.log.Error("Failed to delete expired snapshots", "error", err.Error())
+	} else {
+		service.log.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows)
+	}
 }
 }
 
 
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
-	bus.Dispatch(&m.DeleteExpiredVersionsCommand{})
+	cmd := m.DeleteExpiredVersionsCommand{}
+	if err := bus.Dispatch(&cmd); err != nil {
+		service.log.Error("Failed to delete expired dashboard versions", "error", err.Error())
+	} else {
+		service.log.Debug("Deleted old/expired dashboard versions", "rows affected", cmd.DeletedRows)
+	}
 }
 }
 
 
 func (service *CleanUpService) deleteOldLoginAttempts() {
 func (service *CleanUpService) deleteOldLoginAttempts() {

+ 16 - 11
pkg/services/dashboards/dashboard_service.go

@@ -41,7 +41,10 @@ type SaveDashboardDTO struct {
 	Dashboard *models.Dashboard
 	Dashboard *models.Dashboard
 }
 }
 
 
-type dashboardServiceImpl struct{}
+type dashboardServiceImpl struct {
+	orgId int64
+	user  *models.SignedInUser
+}
 
 
 func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
 func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
 	cmd := &models.GetProvisionedDashboardDataQuery{Name: name}
 	cmd := &models.GetProvisionedDashboardDataQuery{Name: name}
@@ -53,7 +56,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod
 	return cmd.Result, nil
 	return cmd.Result, nil
 }
 }
 
 
-func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) {
+func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) {
 	dash := dto.Dashboard
 	dash := dto.Dashboard
 
 
 	dash.Title = strings.TrimSpace(dash.Title)
 	dash.Title = strings.TrimSpace(dash.Title)
@@ -78,13 +81,15 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO)
 		return nil, models.ErrDashboardUidToLong
 		return nil, models.ErrDashboardUidToLong
 	}
 	}
 
 
-	validateAlertsCmd := models.ValidateDashboardAlertsCommand{
-		OrgId:     dto.OrgId,
-		Dashboard: dash,
-	}
+	if validateAlerts {
+		validateAlertsCmd := models.ValidateDashboardAlertsCommand{
+			OrgId:     dto.OrgId,
+			Dashboard: dash,
+		}
 
 
-	if err := bus.Dispatch(&validateAlertsCmd); err != nil {
-		return nil, models.ErrDashboardContainsInvalidAlertData
+		if err := bus.Dispatch(&validateAlertsCmd); err != nil {
+			return nil, models.ErrDashboardContainsInvalidAlertData
+		}
 	}
 	}
 
 
 	validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{
 	validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{
@@ -142,7 +147,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
 		UserId:  0,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 		OrgRole: models.ROLE_ADMIN,
 	}
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -172,7 +177,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 		UserId:  0,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 		OrgRole: models.ROLE_ADMIN,
 	}
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -191,7 +196,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 }
 }
 
 
 func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
 func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}

+ 2 - 51
pkg/services/dashboards/dashboard_service_test.go

@@ -17,7 +17,7 @@ func TestDashboardService(t *testing.T) {
 		service := dashboardServiceImpl{}
 		service := dashboardServiceImpl{}
 
 
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(&fakeDashboardGuardian{canSave: true})
+		guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
 
 
 		Convey("Save dashboard validation", func() {
 		Convey("Save dashboard validation", func() {
 			dto := &SaveDashboardDTO{}
 			dto := &SaveDashboardDTO{}
@@ -72,7 +72,7 @@ func TestDashboardService(t *testing.T) {
 					dto.Dashboard.SetUid(tc.Uid)
 					dto.Dashboard.SetUid(tc.Uid)
 					dto.User = &models.SignedInUser{}
 					dto.User = &models.SignedInUser{}
 
 
-					_, err := service.buildSaveDashboardCommand(dto)
+					_, err := service.buildSaveDashboardCommand(dto, true)
 					So(err, ShouldEqual, tc.Error)
 					So(err, ShouldEqual, tc.Error)
 				}
 				}
 			})
 			})
@@ -93,52 +93,3 @@ func TestDashboardService(t *testing.T) {
 		})
 		})
 	})
 	})
 }
 }
-
-func mockDashboardGuardian(mock *fakeDashboardGuardian) {
-	guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian {
-		mock.orgId = orgId
-		mock.dashId = dashId
-		mock.user = user
-		return mock
-	}
-}
-
-type fakeDashboardGuardian struct {
-	dashId                      int64
-	orgId                       int64
-	user                        *models.SignedInUser
-	canSave                     bool
-	canEdit                     bool
-	canView                     bool
-	canAdmin                    bool
-	hasPermission               bool
-	checkPermissionBeforeUpdate bool
-}
-
-func (g *fakeDashboardGuardian) CanSave() (bool, error) {
-	return g.canSave, nil
-}
-
-func (g *fakeDashboardGuardian) CanEdit() (bool, error) {
-	return g.canEdit, nil
-}
-
-func (g *fakeDashboardGuardian) CanView() (bool, error) {
-	return g.canView, nil
-}
-
-func (g *fakeDashboardGuardian) CanAdmin() (bool, error) {
-	return g.canAdmin, nil
-}
-
-func (g *fakeDashboardGuardian) HasPermission(permission models.PermissionType) (bool, error) {
-	return g.hasPermission, nil
-}
-
-func (g *fakeDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) {
-	return g.checkPermissionBeforeUpdate, nil
-}
-
-func (g *fakeDashboardGuardian) GetAcl() ([]*models.DashboardAclInfoDTO, error) {
-	return nil, nil
-}

+ 245 - 0
pkg/services/dashboards/folder_service.go

@@ -0,0 +1,245 @@
+package dashboards
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+// FolderService service for operating on folders
+type FolderService interface {
+	GetFolders(limit int) ([]*models.Folder, error)
+	GetFolderById(id int64) (*models.Folder, error)
+	GetFolderByUid(uid string) (*models.Folder, error)
+	CreateFolder(cmd *models.CreateFolderCommand) error
+	UpdateFolder(uid string, cmd *models.UpdateFolderCommand) error
+	DeleteFolder(uid string) (*models.Folder, error)
+}
+
+// NewFolderService factory for creating a new folder service
+var NewFolderService = func(orgId int64, user *models.SignedInUser) FolderService {
+	return &dashboardServiceImpl{
+		orgId: orgId,
+		user:  user,
+	}
+}
+
+func (dr *dashboardServiceImpl) GetFolders(limit int) ([]*models.Folder, error) {
+	if limit == 0 {
+		limit = 1000
+	}
+
+	searchQuery := search.Query{
+		SignedInUser: dr.user,
+		DashboardIds: make([]int64, 0),
+		FolderIds:    make([]int64, 0),
+		Limit:        limit,
+		OrgId:        dr.orgId,
+		Type:         "dash-folder",
+		Permission:   models.PERMISSION_VIEW,
+	}
+
+	if err := bus.Dispatch(&searchQuery); err != nil {
+		return nil, err
+	}
+
+	folders := make([]*models.Folder, 0)
+
+	for _, hit := range searchQuery.Result {
+		folders = append(folders, &models.Folder{
+			Id:    hit.Id,
+			Uid:   hit.Uid,
+			Title: hit.Title,
+		})
+	}
+
+	return folders, nil
+}
+
+func (dr *dashboardServiceImpl) GetFolderById(id int64) (*models.Folder, error) {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id}
+	dashFolder, err := getFolder(query)
+
+	if err != nil {
+		return nil, toFolderError(err)
+	}
+
+	g := guardian.New(dashFolder.Id, dr.orgId, dr.user)
+	if canView, err := g.CanView(); err != nil || !canView {
+		if err != nil {
+			return nil, toFolderError(err)
+		}
+		return nil, models.ErrFolderAccessDenied
+	}
+
+	return dashToFolder(dashFolder), nil
+}
+
+func (dr *dashboardServiceImpl) GetFolderByUid(uid string) (*models.Folder, error) {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid}
+	dashFolder, err := getFolder(query)
+
+	if err != nil {
+		return nil, toFolderError(err)
+	}
+
+	g := guardian.New(dashFolder.Id, dr.orgId, dr.user)
+	if canView, err := g.CanView(); err != nil || !canView {
+		if err != nil {
+			return nil, toFolderError(err)
+		}
+		return nil, models.ErrFolderAccessDenied
+	}
+
+	return dashToFolder(dashFolder), nil
+}
+
+func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) error {
+	dashFolder := cmd.GetDashboardModel(dr.orgId, dr.user.UserId)
+
+	dto := &SaveDashboardDTO{
+		Dashboard: dashFolder,
+		OrgId:     dr.orgId,
+		User:      dr.user,
+	}
+
+	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	err = bus.Dispatch(saveDashboardCmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id}
+	dashFolder, err = getFolder(query)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	cmd.Result = dashToFolder(dashFolder)
+
+	return nil
+}
+
+func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid}
+	dashFolder, err := getFolder(query)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId)
+
+	dto := &SaveDashboardDTO{
+		Dashboard: dashFolder,
+		OrgId:     dr.orgId,
+		User:      dr.user,
+		Overwrite: cmd.Overwrite,
+	}
+
+	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	err = bus.Dispatch(saveDashboardCmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	query = models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id}
+	dashFolder, err = getFolder(query)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	cmd.Result = dashToFolder(dashFolder)
+
+	return nil
+}
+
+func (dr *dashboardServiceImpl) DeleteFolder(uid string) (*models.Folder, error) {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid}
+	dashFolder, err := getFolder(query)
+	if err != nil {
+		return nil, toFolderError(err)
+	}
+
+	guardian := guardian.New(dashFolder.Id, dr.orgId, dr.user)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		if err != nil {
+			return nil, toFolderError(err)
+		}
+		return nil, models.ErrFolderAccessDenied
+	}
+
+	deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id}
+	if err := bus.Dispatch(&deleteCmd); err != nil {
+		return nil, toFolderError(err)
+	}
+
+	return dashToFolder(dashFolder), nil
+}
+
+func getFolder(query models.GetDashboardQuery) (*models.Dashboard, error) {
+	if err := bus.Dispatch(&query); err != nil {
+		return nil, toFolderError(err)
+	}
+
+	if !query.Result.IsFolder {
+		return nil, models.ErrFolderNotFound
+	}
+
+	return query.Result, nil
+}
+
+func dashToFolder(dash *models.Dashboard) *models.Folder {
+	return &models.Folder{
+		Id:        dash.Id,
+		Uid:       dash.Uid,
+		Title:     dash.Title,
+		HasAcl:    dash.HasAcl,
+		Url:       dash.GetUrl(),
+		Version:   dash.Version,
+		Created:   dash.Created,
+		CreatedBy: dash.CreatedBy,
+		Updated:   dash.Updated,
+		UpdatedBy: dash.UpdatedBy,
+	}
+}
+
+func toFolderError(err error) error {
+	if err == models.ErrDashboardTitleEmpty {
+		return models.ErrFolderTitleEmpty
+	}
+
+	if err == models.ErrDashboardUpdateAccessDenied {
+		return models.ErrFolderAccessDenied
+	}
+
+	if err == models.ErrDashboardWithSameNameInFolderExists {
+		return models.ErrFolderSameNameExists
+	}
+
+	if err == models.ErrDashboardWithSameUIDExists {
+		return models.ErrFolderWithSameUIDExists
+	}
+
+	if err == models.ErrDashboardVersionMismatch {
+		return models.ErrFolderVersionMismatch
+	}
+
+	if err == models.ErrDashboardNotFound {
+		return models.ErrFolderNotFound
+	}
+
+	if err == models.ErrDashboardFailedGenerateUniqueUid {
+		err = models.ErrFolderFailedGenerateUniqueUid
+	}
+
+	return err
+}

+ 191 - 0
pkg/services/dashboards/folder_service_test.go

@@ -0,0 +1,191 @@
+package dashboards
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+
+	"github.com/grafana/grafana/pkg/services/guardian"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestFolderService(t *testing.T) {
+	Convey("Folder service tests", t, func() {
+		service := dashboardServiceImpl{
+			orgId: 1,
+			user:  &models.SignedInUser{UserId: 1},
+		}
+
+		Convey("Given user has no permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{})
+
+			bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
+				query.Result = models.NewDashboardFolder("Folder")
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+				return models.ErrDashboardUpdateAccessDenied
+			})
+
+			Convey("When get folder by id should return access denied error", func() {
+				_, err := service.GetFolderById(1)
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When get folder by uid should return access denied error", func() {
+				_, err := service.GetFolderByUid("uid")
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When creating folder should return access denied error", func() {
+				err := service.CreateFolder(&models.CreateFolderCommand{
+					Title: "Folder",
+				})
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When updating folder should return access denied error", func() {
+				err := service.UpdateFolder("uid", &models.UpdateFolderCommand{
+					Uid:   "uid",
+					Title: "Folder",
+				})
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When deleting folder by uid should return access denied error", func() {
+				_, err := service.DeleteFolder("uid")
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Given user has permission to save", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
+
+			dash := models.NewDashboardFolder("Folder")
+			dash.Id = 1
+
+			bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
+				query.Result = dash
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.SaveDashboardCommand) error {
+				cmd.Result = dash
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error {
+				return nil
+			})
+
+			Convey("When creating folder should not return access denied error", func() {
+				err := service.CreateFolder(&models.CreateFolderCommand{
+					Title: "Folder",
+				})
+				So(err, ShouldBeNil)
+			})
+
+			Convey("When updating folder should not return access denied error", func() {
+				err := service.UpdateFolder("uid", &models.UpdateFolderCommand{
+					Uid:   "uid",
+					Title: "Folder",
+				})
+				So(err, ShouldBeNil)
+			})
+
+			Convey("When deleting folder by uid should not return access denied error", func() {
+				_, err := service.DeleteFolder("uid")
+				So(err, ShouldBeNil)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Given user has permission to view", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true})
+
+			dashFolder := models.NewDashboardFolder("Folder")
+			dashFolder.Id = 1
+			dashFolder.Uid = "uid-abc"
+
+			bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
+				query.Result = dashFolder
+				return nil
+			})
+
+			Convey("When get folder by id should return folder", func() {
+				f, _ := service.GetFolderById(1)
+				So(f.Id, ShouldEqual, dashFolder.Id)
+				So(f.Uid, ShouldEqual, dashFolder.Uid)
+				So(f.Title, ShouldEqual, dashFolder.Title)
+			})
+
+			Convey("When get folder by uid should return folder", func() {
+				f, _ := service.GetFolderByUid("uid")
+				So(f.Id, ShouldEqual, dashFolder.Id)
+				So(f.Uid, ShouldEqual, dashFolder.Uid)
+				So(f.Title, ShouldEqual, dashFolder.Title)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Should map errors correct", func() {
+			testCases := []struct {
+				ActualError   error
+				ExpectedError error
+			}{
+				{ActualError: models.ErrDashboardTitleEmpty, ExpectedError: models.ErrFolderTitleEmpty},
+				{ActualError: models.ErrDashboardUpdateAccessDenied, ExpectedError: models.ErrFolderAccessDenied},
+				{ActualError: models.ErrDashboardWithSameNameInFolderExists, ExpectedError: models.ErrFolderSameNameExists},
+				{ActualError: models.ErrDashboardWithSameUIDExists, ExpectedError: models.ErrFolderWithSameUIDExists},
+				{ActualError: models.ErrDashboardVersionMismatch, ExpectedError: models.ErrFolderVersionMismatch},
+				{ActualError: models.ErrDashboardNotFound, ExpectedError: models.ErrFolderNotFound},
+				{ActualError: models.ErrDashboardFailedGenerateUniqueUid, ExpectedError: models.ErrFolderFailedGenerateUniqueUid},
+				{ActualError: models.ErrDashboardInvalidUid, ExpectedError: models.ErrDashboardInvalidUid},
+			}
+
+			for _, tc := range testCases {
+				actualError := toFolderError(tc.ActualError)
+				if actualError != tc.ExpectedError {
+					t.Errorf("For error '%s' expected error '%s', actual '%s'", tc.ActualError, tc.ExpectedError, actualError)
+				}
+			}
+		})
+	})
+}

+ 107 - 5
pkg/services/guardian/guardian.go

@@ -1,12 +1,19 @@
 package guardian
 package guardian
 
 
 import (
 import (
+	"errors"
+
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
+var (
+	ErrGuardianPermissionExists = errors.New("Permission already exists")
+	ErrGuardianOverride         = errors.New("You can only override a permission to be higher")
+)
+
 // DashboardGuardian to be used for guard against operations without access on dashboard and acl
 // DashboardGuardian to be used for guard against operations without access on dashboard and acl
 type DashboardGuardian interface {
 type DashboardGuardian interface {
 	CanSave() (bool, error)
 	CanSave() (bool, error)
@@ -119,14 +126,51 @@ func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.D
 }
 }
 
 
 func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
 func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
-	if g.user.OrgRole == m.ROLE_ADMIN {
-		return true, nil
-	}
-
 	acl := []*m.DashboardAclInfoDTO{}
 	acl := []*m.DashboardAclInfoDTO{}
+	adminRole := m.ROLE_ADMIN
+	everyoneWithAdminRole := &m.DashboardAclInfoDTO{DashboardId: g.dashId, UserId: 0, TeamId: 0, Role: &adminRole, Permission: m.PERMISSION_ADMIN}
 
 
+	// validate that duplicate permissions don't exists
 	for _, p := range updatePermissions {
 	for _, p := range updatePermissions {
-		acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission})
+		aclItem := &m.DashboardAclInfoDTO{DashboardId: p.DashboardId, UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission}
+		if aclItem.IsDuplicateOf(everyoneWithAdminRole) {
+			return false, ErrGuardianPermissionExists
+		}
+
+		for _, a := range acl {
+			if a.IsDuplicateOf(aclItem) {
+				return false, ErrGuardianPermissionExists
+			}
+		}
+
+		acl = append(acl, aclItem)
+	}
+
+	existingPermissions, err := g.GetAcl()
+	if err != nil {
+		return false, err
+	}
+
+	// validate overridden permissions to be higher
+	for _, a := range acl {
+		for _, existingPerm := range existingPermissions {
+			// handle default permissions
+			if existingPerm.DashboardId == -1 {
+				existingPerm.DashboardId = g.dashId
+			}
+
+			if a.DashboardId == existingPerm.DashboardId {
+				continue
+			}
+
+			if a.IsDuplicateOf(existingPerm) && a.Permission <= existingPerm.Permission {
+				return false, ErrGuardianOverride
+			}
+		}
+	}
+
+	if g.user.OrgRole == m.ROLE_ADMIN {
+		return true, nil
 	}
 	}
 
 
 	return g.checkAcl(permission, acl)
 	return g.checkAcl(permission, acl)
@@ -143,6 +187,13 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	for _, a := range query.Result {
+		// handle default permissions
+		if a.DashboardId == -1 {
+			a.DashboardId = g.dashId
+		}
+	}
+
 	g.acl = query.Result
 	g.acl = query.Result
 	return g.acl, nil
 	return g.acl, nil
 }
 }
@@ -158,3 +209,54 @@ func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) {
 	g.groups = query.Result
 	g.groups = query.Result
 	return query.Result, err
 	return query.Result, err
 }
 }
+
+type FakeDashboardGuardian struct {
+	DashId                           int64
+	OrgId                            int64
+	User                             *m.SignedInUser
+	CanSaveValue                     bool
+	CanEditValue                     bool
+	CanViewValue                     bool
+	CanAdminValue                    bool
+	HasPermissionValue               bool
+	CheckPermissionBeforeUpdateValue bool
+	CheckPermissionBeforeUpdateError error
+	GetAclValue                      []*m.DashboardAclInfoDTO
+}
+
+func (g *FakeDashboardGuardian) CanSave() (bool, error) {
+	return g.CanSaveValue, nil
+}
+
+func (g *FakeDashboardGuardian) CanEdit() (bool, error) {
+	return g.CanEditValue, nil
+}
+
+func (g *FakeDashboardGuardian) CanView() (bool, error) {
+	return g.CanViewValue, nil
+}
+
+func (g *FakeDashboardGuardian) CanAdmin() (bool, error) {
+	return g.CanAdminValue, nil
+}
+
+func (g *FakeDashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) {
+	return g.HasPermissionValue, nil
+}
+
+func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
+	return g.CheckPermissionBeforeUpdateValue, g.CheckPermissionBeforeUpdateError
+}
+
+func (g *FakeDashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
+	return g.GetAclValue, nil
+}
+
+func MockDashboardGuardian(mock *FakeDashboardGuardian) {
+	New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian {
+		mock.OrgId = orgId
+		mock.DashId = dashId
+		mock.User = user
+		return mock
+	}
+}

+ 711 - 0
pkg/services/guardian/guardian_test.go

@@ -0,0 +1,711 @@
+package guardian
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestGuardian(t *testing.T) {
+	Convey("Guardian permission tests", t, func() {
+		orgRoleScenario("Given user has admin org role", m.ROLE_ADMIN, func(sc *scenarioContext) {
+			canAdmin, _ := sc.g.CanAdmin()
+			canEdit, _ := sc.g.CanEdit()
+			canSave, _ := sc.g.CanSave()
+			canView, _ := sc.g.CanView()
+			So(canAdmin, ShouldBeTrue)
+			So(canEdit, ShouldBeTrue)
+			So(canSave, ShouldBeTrue)
+			So(canView, ShouldBeTrue)
+
+			Convey("When trying to update permissions", func() {
+				Convey("With duplicate user permissions should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW},
+						{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianPermissionExists)
+				})
+
+				Convey("With duplicate team permissions should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
+						{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianPermissionExists)
+				})
+
+				Convey("With duplicate everyone with editor role permission should return error", func() {
+					r := m.ROLE_EDITOR
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW},
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianPermissionExists)
+				})
+
+				Convey("With duplicate everyone with viewer role permission should return error", func() {
+					r := m.ROLE_VIEWER
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW},
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianPermissionExists)
+				})
+
+				Convey("With everyone with admin role permission should return error", func() {
+					r := m.ROLE_ADMIN
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianPermissionExists)
+				})
+			})
+
+			Convey("Given default permissions", func() {
+				editor := m.ROLE_EDITOR
+				viewer := m.ROLE_VIEWER
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: -1, Role: &editor, Permission: m.PERMISSION_EDIT},
+					{OrgId: 1, DashboardId: -1, Role: &viewer, Permission: m.PERMISSION_VIEW},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions without everyone with role editor can edit should be allowed", func() {
+					r := m.ROLE_VIEWER
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions without everyone with role viewer can view should be allowed", func() {
+					r := m.ROLE_EDITOR
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_EDIT},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+			})
+
+			Convey("Given parent folder has user admin permission", func() {
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with admin user permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with edit user permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with view user permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has user edit permission", func() {
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with edit user permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with view user permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has user view permission", func() {
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with edit user permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with view user permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has team admin permission", func() {
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_ADMIN},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with admin team permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with edit team permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with view team permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has team edit permission", func() {
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_EDIT},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with edit team permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with view team permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has team view permission", func() {
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_VIEW},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with edit team permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with view team permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has editor role with edit permission", func() {
+				r := m.ROLE_EDITOR
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_EDIT},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with everyone with editor role can admin permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with everyone with editor role can edit permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+
+				Convey("When trying to update dashboard permissions with everyone with editor role can view permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+
+			Convey("Given parent folder has editor role with view permission", func() {
+				r := m.ROLE_EDITOR
+				existingPermissions := []*m.DashboardAclInfoDTO{
+					{OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_VIEW},
+				}
+
+				bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+					query.Result = existingPermissions
+					return nil
+				})
+
+				Convey("When trying to update dashboard permissions with everyone with viewer role can admin permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with everyone with viewer role can edit permission should be allowed", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT},
+					}
+					ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(ok, ShouldBeTrue)
+				})
+
+				Convey("When trying to update dashboard permissions with everyone with viewer role can view permission should return error", func() {
+					p := []*m.DashboardAcl{
+						{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW},
+					}
+					_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+					So(err, ShouldEqual, ErrGuardianOverride)
+				})
+			})
+		})
+
+		orgRoleScenario("Given user has editor org role", m.ROLE_EDITOR, func(sc *scenarioContext) {
+			everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeTrue)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeTrue)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeFalse)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeFalse)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeFalse)
+			})
+
+			userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeTrue)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeTrue)
+			})
+
+			teamWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeTrue)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			teamWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			teamWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeTrue)
+			})
+
+			Convey("When trying to update permissions should return false", func() {
+				p := []*m.DashboardAcl{
+					{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW},
+					{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN},
+				}
+				ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+				So(ok, ShouldBeFalse)
+			})
+		})
+
+		orgRoleScenario("Given user has viewer org role", m.ROLE_VIEWER, func(sc *scenarioContext) {
+			everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeFalse)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeFalse)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeFalse)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeTrue)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeTrue)
+			})
+
+			userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeTrue)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeTrue)
+				So(canSave, ShouldBeTrue)
+				So(canView, ShouldBeTrue)
+			})
+
+			userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
+				canAdmin, _ := sc.g.CanAdmin()
+				canEdit, _ := sc.g.CanEdit()
+				canSave, _ := sc.g.CanSave()
+				canView, _ := sc.g.CanView()
+				So(canAdmin, ShouldBeFalse)
+				So(canEdit, ShouldBeFalse)
+				So(canSave, ShouldBeFalse)
+				So(canView, ShouldBeTrue)
+			})
+
+			Convey("When trying to update permissions should return false", func() {
+				p := []*m.DashboardAcl{
+					{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW},
+					{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN},
+				}
+				ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
+				So(ok, ShouldBeFalse)
+			})
+		})
+	})
+}
+
+type scenarioContext struct {
+	g DashboardGuardian
+}
+
+type scenarioFunc func(c *scenarioContext)
+
+func orgRoleScenario(desc string, role m.RoleType, fn scenarioFunc) {
+	user := &m.SignedInUser{
+		UserId:  1,
+		OrgId:   1,
+		OrgRole: role,
+	}
+	guard := New(1, 1, user)
+	sc := &scenarioContext{
+		g: guard,
+	}
+
+	Convey(desc, func() {
+		fn(sc)
+	})
+}
+
+func permissionScenario(desc string, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) {
+	bus.ClearBusHandlers()
+
+	bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+		query.Result = permissions
+		return nil
+	})
+
+	teams := []*m.Team{}
+
+	for _, p := range permissions {
+		if p.TeamId > 0 {
+			teams = append(teams, &m.Team{Id: p.TeamId})
+		}
+	}
+
+	bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+		query.Result = teams
+		return nil
+	})
+
+	Convey(desc, func() {
+		fn(sc)
+	})
+}
+
+func userWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) {
+	p := []*m.DashboardAclInfoDTO{
+		{OrgId: 1, DashboardId: 1, UserId: 1, Permission: permission},
+	}
+	permissionScenario(fmt.Sprintf("and user has permission to %s item", permission), sc, p, fn)
+}
+
+func teamWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) {
+	p := []*m.DashboardAclInfoDTO{
+		{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: permission},
+	}
+	permissionScenario(fmt.Sprintf("and team has permission to %s item", permission), sc, p, fn)
+}
+
+func everyoneWithRoleScenario(role m.RoleType, permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) {
+	p := []*m.DashboardAclInfoDTO{
+		{OrgId: 1, DashboardId: 1, UserId: -1, Role: &role, Permission: permission},
+	}
+	permissionScenario(fmt.Sprintf("and everyone with %s role can %s item", role, permission), sc, p, fn)
+}

+ 16 - 4
pkg/services/sqlstore/dashboard.go

@@ -37,6 +37,12 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error {
 func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error {
 	dash := cmd.GetDashboardModel()
 	dash := cmd.GetDashboardModel()
 
 
+	userId := cmd.UserId
+
+	if userId == 0 {
+		userId = -1
+	}
+
 	if dash.Id > 0 {
 	if dash.Id > 0 {
 		var existing m.Dashboard
 		var existing m.Dashboard
 		dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
 		dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
@@ -76,17 +82,23 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error {
 
 
 	if dash.Id == 0 {
 	if dash.Id == 0 {
 		dash.SetVersion(1)
 		dash.SetVersion(1)
+		dash.Created = time.Now()
+		dash.CreatedBy = userId
+		dash.Updated = time.Now()
+		dash.UpdatedBy = userId
 		metrics.M_Api_Dashboard_Insert.Inc()
 		metrics.M_Api_Dashboard_Insert.Inc()
 		affectedRows, err = sess.Insert(dash)
 		affectedRows, err = sess.Insert(dash)
 	} else {
 	} else {
-		v := dash.Version
-		v++
-		dash.SetVersion(v)
+		dash.SetVersion(dash.Version + 1)
 
 
 		if !cmd.UpdatedAt.IsZero() {
 		if !cmd.UpdatedAt.IsZero() {
 			dash.Updated = cmd.UpdatedAt
 			dash.Updated = cmd.UpdatedAt
+		} else {
+			dash.Updated = time.Now()
 		}
 		}
 
 
+		dash.UpdatedBy = userId
+
 		affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
 		affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
 	}
 	}
 
 
@@ -514,7 +526,7 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash
 		}
 		}
 
 
 		if !folderExists {
 		if !folderExists {
-			return m.ErrFolderNotFound
+			return m.ErrDashboardFolderNotFound
 		}
 		}
 	}
 	}
 
 

+ 23 - 75
pkg/services/sqlstore/dashboard_service_integration_test.go

@@ -142,9 +142,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, 0)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 
 
@@ -165,9 +165,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, otherSavedFolder.Id)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 
 
@@ -189,9 +189,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInGeneralFolder.Id)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInGeneralFolder.Id)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 
 
@@ -213,9 +213,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInFolder.Id)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInFolder.Id)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 			})
 			})
@@ -363,7 +363,7 @@ func TestIntegratedDashboardService(t *testing.T) {
 
 
 						Convey("It should result in folder not found error", func() {
 						Convey("It should result in folder not found error", func() {
 							So(err, ShouldNotBeNil)
 							So(err, ShouldNotBeNil)
-							So(err, ShouldEqual, models.ErrFolderNotFound)
+							So(err, ShouldEqual, models.ErrDashboardFolderNotFound)
 						})
 						})
 					})
 					})
 
 
@@ -785,68 +785,16 @@ func TestIntegratedDashboardService(t *testing.T) {
 	})
 	})
 }
 }
 
 
-func mockDashboardGuardian(mock *mockDashboardGuarder) {
-	guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian {
-		mock.orgId = orgId
-		mock.dashId = dashId
-		mock.user = user
-		return mock
-	}
-}
-
-type mockDashboardGuarder struct {
-	dashId                      int64
-	orgId                       int64
-	user                        *models.SignedInUser
-	canSave                     bool
-	canSaveCallCounter          int
-	canEdit                     bool
-	canView                     bool
-	canAdmin                    bool
-	hasPermission               bool
-	checkPermissionBeforeRemove bool
-	checkPermissionBeforeUpdate bool
-}
-
-func (g *mockDashboardGuarder) CanSave() (bool, error) {
-	g.canSaveCallCounter++
-	return g.canSave, nil
-}
-
-func (g *mockDashboardGuarder) CanEdit() (bool, error) {
-	return g.canEdit, nil
-}
-
-func (g *mockDashboardGuarder) CanView() (bool, error) {
-	return g.canView, nil
-}
-
-func (g *mockDashboardGuarder) CanAdmin() (bool, error) {
-	return g.canAdmin, nil
-}
-
-func (g *mockDashboardGuarder) HasPermission(permission models.PermissionType) (bool, error) {
-	return g.hasPermission, nil
-}
-
-func (g *mockDashboardGuarder) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) {
-	return g.checkPermissionBeforeUpdate, nil
-}
-
-func (g *mockDashboardGuarder) GetAcl() ([]*models.DashboardAclInfoDTO, error) {
-	return nil, nil
-}
-
 type scenarioContext struct {
 type scenarioContext struct {
-	dashboardGuardianMock *mockDashboardGuarder
+	dashboardGuardianMock *guardian.FakeDashboardGuardian
 }
 }
 
 
 type scenarioFunc func(c *scenarioContext)
 type scenarioFunc func(c *scenarioContext)
 
 
-func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) {
+func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
 	Convey(desc, func() {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 
 		sc := &scenarioContext{
 		sc := &scenarioContext{
 			dashboardGuardianMock: mock,
 			dashboardGuardianMock: mock,
@@ -861,15 +809,15 @@ func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scena
 }
 }
 
 
 type dashboardPermissionScenarioContext struct {
 type dashboardPermissionScenarioContext struct {
-	dashboardGuardianMock *mockDashboardGuarder
+	dashboardGuardianMock *guardian.FakeDashboardGuardian
 }
 }
 
 
 type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext)
 type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext)
 
 
-func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn dashboardPermissionScenarioFunc) {
+func dashboardPermissionScenario(desc string, mock *guardian.FakeDashboardGuardian, fn dashboardPermissionScenarioFunc) {
 	Convey(desc, func() {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 
 		sc := &dashboardPermissionScenarioContext{
 		sc := &dashboardPermissionScenarioContext{
 			dashboardGuardianMock: mock,
 			dashboardGuardianMock: mock,
@@ -884,8 +832,8 @@ func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn das
 }
 }
 
 
 func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) {
 func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) {
-	mock := &mockDashboardGuarder{
-		canSave: canSave,
+	mock := &guardian.FakeDashboardGuardian{
+		CanSaveValue: canSave,
 	}
 	}
 	dashboardPermissionScenario(desc, mock, fn)
 	dashboardPermissionScenario(desc, mock, fn)
 }
 }
@@ -902,10 +850,10 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error {
 	return err
 	return err
 }
 }
 
 
-func dashboardServiceScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) {
+func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
 	Convey(desc, func() {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 
 		sc := &scenarioContext{
 		sc := &scenarioContext{
 			dashboardGuardianMock: mock,
 			dashboardGuardianMock: mock,

+ 26 - 12
pkg/services/sqlstore/dashboard_snapshot.go

@@ -16,20 +16,23 @@ func init() {
 	bus.AddHandler("sql", DeleteExpiredSnapshots)
 	bus.AddHandler("sql", DeleteExpiredSnapshots)
 }
 }
 
 
+// DeleteExpiredSnapshots removes snapshots with old expiry dates.
+// SnapShotRemoveExpired is deprecated and should be removed in the future.
+// Snapshot expiry is decided by the user when they share the snapshot.
 func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error {
 func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		var expiredCount int64 = 0
-
-		if setting.SnapShotRemoveExpired {
-			deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?"
-			expiredResponse, err := x.Exec(deleteExpiredSql, time.Now)
-			if err != nil {
-				return err
-			}
-			expiredCount, _ = expiredResponse.RowsAffected()
+		if !setting.SnapShotRemoveExpired {
+			sqlog.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
+			return nil
 		}
 		}
 
 
-		sqlog.Debug("Deleted old/expired snaphots", "expired", expiredCount)
+		deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?"
+		expiredResponse, err := sess.Exec(deleteExpiredSql, time.Now())
+		if err != nil {
+			return err
+		}
+		cmd.DeletedRows, _ = expiredResponse.RowsAffected()
+
 		return nil
 		return nil
 	})
 	})
 }
 }
@@ -72,7 +75,7 @@ func DeleteDashboardSnapshot(cmd *m.DeleteDashboardSnapshotCommand) error {
 }
 }
 
 
 func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
 func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
-	snapshot := m.DashboardSnapshot{Key: query.Key}
+	snapshot := m.DashboardSnapshot{Key: query.Key, DeleteKey: query.DeleteKey}
 	has, err := x.Get(&snapshot)
 	has, err := x.Get(&snapshot)
 
 
 	if err != nil {
 	if err != nil {
@@ -85,6 +88,8 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
 	return nil
 	return nil
 }
 }
 
 
+// SearchDashboardSnapshots returns a list of all snapshots for admins
+// for other roles, it returns snapshots created by the user
 func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 	var snapshots = make(m.DashboardSnapshotsList, 0)
 	var snapshots = make(m.DashboardSnapshotsList, 0)
 
 
@@ -95,7 +100,16 @@ func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 		sess.Where("name LIKE ?", query.Name)
 		sess.Where("name LIKE ?", query.Name)
 	}
 	}
 
 
-	sess.Where("org_id = ?", query.OrgId)
+	// admins can see all snapshots, everyone else can only see their own snapshots
+	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
+		sess.Where("org_id = ?", query.OrgId)
+	} else if !query.SignedInUser.IsAnonymous {
+		sess.Where("org_id = ? AND user_id = ?", query.OrgId, query.SignedInUser.UserId)
+	} else {
+		query.Result = snapshots
+		return nil
+	}
+
 	err := sess.Find(&snapshots)
 	err := sess.Find(&snapshots)
 	query.Result = snapshots
 	query.Result = snapshots
 	return err
 	return err

+ 136 - 2
pkg/services/sqlstore/dashboard_snapshot_test.go

@@ -2,11 +2,14 @@ package sqlstore
 
 
 import (
 import (
 	"testing"
 	"testing"
+	"time"
 
 
+	"github.com/go-xorm/xorm"
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
 func TestDashboardSnapshotDBAccess(t *testing.T) {
 func TestDashboardSnapshotDBAccess(t *testing.T) {
@@ -14,17 +17,19 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 	Convey("Testing DashboardSnapshot data access", t, func() {
 	Convey("Testing DashboardSnapshot data access", t, func() {
 		InitTestDB(t)
 		InitTestDB(t)
 
 
-		Convey("Given saved snaphot", func() {
+		Convey("Given saved snapshot", func() {
 			cmd := m.CreateDashboardSnapshotCommand{
 			cmd := m.CreateDashboardSnapshotCommand{
 				Key: "hej",
 				Key: "hej",
 				Dashboard: simplejson.NewFromAny(map[string]interface{}{
 				Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					"hello": "mupp",
 					"hello": "mupp",
 				}),
 				}),
+				UserId: 1000,
+				OrgId:  1,
 			}
 			}
 			err := CreateDashboardSnapshot(&cmd)
 			err := CreateDashboardSnapshot(&cmd)
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
 
 
-			Convey("Should be able to get snaphot by key", func() {
+			Convey("Should be able to get snapshot by key", func() {
 				query := m.GetDashboardSnapshotQuery{Key: "hej"}
 				query := m.GetDashboardSnapshotQuery{Key: "hej"}
 				err = GetDashboardSnapshot(&query)
 				err = GetDashboardSnapshot(&query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
@@ -33,6 +38,135 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 				So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp")
 				So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp")
 			})
 			})
 
 
+			Convey("And the user has the admin role", func() {
+				Convey("Should return all the snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+				})
+			})
+
+			Convey("And the user has the editor role and has created a snapshot", func() {
+				Convey("Should return all the snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 1000},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+				})
+			})
+
+			Convey("And the user has the editor role and has not created any snapshot", func() {
+				Convey("Should not return any snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 2},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 0)
+				})
+			})
+
+			Convey("And the user is anonymous", func() {
+				cmd := m.CreateDashboardSnapshotCommand{
+					Key:       "strangesnapshotwithuserid0",
+					DeleteKey: "adeletekey",
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"hello": "mupp",
+					}),
+					UserId: 0,
+					OrgId:  1,
+				}
+				err := CreateDashboardSnapshot(&cmd)
+				So(err, ShouldBeNil)
+
+				Convey("Should not return any snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, IsAnonymous: true, UserId: 0},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 0)
+				})
+			})
 		})
 		})
 	})
 	})
 }
 }
+
+func TestDeleteExpiredSnapshots(t *testing.T) {
+	Convey("Testing dashboard snapshots clean up", t, func() {
+		x := InitTestDB(t)
+
+		setting.SnapShotRemoveExpired = true
+
+		notExpiredsnapshot := createTestSnapshot(x, "key1", 1000)
+		createTestSnapshot(x, "key2", -1000)
+		createTestSnapshot(x, "key3", -1000)
+
+		Convey("Clean up old dashboard snapshots", func() {
+			err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
+			So(err, ShouldBeNil)
+
+			query := m.GetDashboardSnapshotsQuery{
+				OrgId:        1,
+				SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+			}
+			err = SearchDashboardSnapshots(&query)
+			So(err, ShouldBeNil)
+
+			So(len(query.Result), ShouldEqual, 1)
+			So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key)
+		})
+
+		Convey("Don't delete anything if there are no expired snapshots", func() {
+			err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
+			So(err, ShouldBeNil)
+
+			query := m.GetDashboardSnapshotsQuery{
+				OrgId:        1,
+				SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+			}
+			SearchDashboardSnapshots(&query)
+
+			So(len(query.Result), ShouldEqual, 1)
+		})
+	})
+}
+
+func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardSnapshot {
+	cmd := m.CreateDashboardSnapshotCommand{
+		Key:       key,
+		DeleteKey: "delete" + key,
+		Dashboard: simplejson.NewFromAny(map[string]interface{}{
+			"hello": "mupp",
+		}),
+		UserId:  1000,
+		OrgId:   1,
+		Expires: expires,
+	}
+	err := CreateDashboardSnapshot(&cmd)
+	So(err, ShouldBeNil)
+
+	// Set expiry date manually - to be able to create expired snapshots
+	expireDate := time.Now().Add(time.Second * time.Duration(expires))
+	_, err = x.Exec("update dashboard_snapshot set expires = ? where "+dialect.Quote("key")+" = ?", expireDate, key)
+	So(err, ShouldBeNil)
+
+	return cmd.Result
+}

+ 25 - 0
pkg/services/sqlstore/dashboard_test.go

@@ -3,6 +3,7 @@ package sqlstore
 import (
 import (
 	"fmt"
 	"fmt"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/go-xorm/xorm"
 	"github.com/go-xorm/xorm"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -124,6 +125,24 @@ func TestDashboardDataAccess(t *testing.T) {
 				generateNewUid = util.GenerateShortUid
 				generateNewUid = util.GenerateShortUid
 			})
 			})
 
 
+			Convey("Should be able to create dashboard", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "folderId",
+						"tags":  []interface{}{},
+					}),
+					UserId: 100,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.CreatedBy, ShouldEqual, 100)
+				So(cmd.Result.Created.IsZero(), ShouldBeFalse)
+				So(cmd.Result.UpdatedBy, ShouldEqual, 100)
+				So(cmd.Result.Updated.IsZero(), ShouldBeFalse)
+			})
+
 			Convey("Should be able to update dashboard by id and remove folderId", func() {
 			Convey("Should be able to update dashboard by id and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
@@ -134,6 +153,7 @@ func TestDashboardDataAccess(t *testing.T) {
 					}),
 					}),
 					Overwrite: true,
 					Overwrite: true,
 					FolderId:  2,
 					FolderId:  2,
+					UserId:    100,
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
@@ -149,6 +169,7 @@ func TestDashboardDataAccess(t *testing.T) {
 					}),
 					}),
 					FolderId:  0,
 					FolderId:  0,
 					Overwrite: true,
 					Overwrite: true,
+					UserId:    100,
 				}
 				}
 
 
 				err = SaveDashboard(&cmd)
 				err = SaveDashboard(&cmd)
@@ -162,6 +183,10 @@ func TestDashboardDataAccess(t *testing.T) {
 				err = GetDashboard(&query)
 				err = GetDashboard(&query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(query.Result.FolderId, ShouldEqual, 0)
 				So(query.Result.FolderId, ShouldEqual, 0)
+				So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy)
+				So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second))
+				So(query.Result.UpdatedBy, ShouldEqual, 100)
+				So(query.Result.Updated.IsZero(), ShouldBeFalse)
 			})
 			})
 
 
 			Convey("Should be able to delete a dashboard folder and its children", func() {
 			Convey("Should be able to delete a dashboard folder and its children", func() {

+ 1 - 3
pkg/services/sqlstore/dashboard_version.go

@@ -69,7 +69,6 @@ func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error {
 
 
 func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error {
 func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		expiredCount := int64(0)
 		versions := []DashboardVersionExp{}
 		versions := []DashboardVersionExp{}
 		versionsToKeep := setting.DashboardVersionsToKeep
 		versionsToKeep := setting.DashboardVersionsToKeep
 
 
@@ -98,8 +97,7 @@ func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error {
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
-			expiredCount, _ = expiredResponse.RowsAffected()
-			sqlog.Debug("Deleted old/expired dashboard versions", "expired", expiredCount)
+			cmd.DeletedRows, _ = expiredResponse.RowsAffected()
 		}
 		}
 
 
 		return nil
 		return nil

+ 0 - 2
pkg/setting/setting.go

@@ -88,7 +88,6 @@ var (
 	ExternalSnapshotUrl   string
 	ExternalSnapshotUrl   string
 	ExternalSnapshotName  string
 	ExternalSnapshotName  string
 	ExternalEnabled       bool
 	ExternalEnabled       bool
-	SnapShotTTLDays       int
 	SnapShotRemoveExpired bool
 	SnapShotRemoveExpired bool
 
 
 	// Dashboard history
 	// Dashboard history
@@ -523,7 +522,6 @@ func NewConfigContext(args *CommandLineArgs) error {
 	ExternalSnapshotName = snapshots.Key("external_snapshot_name").String()
 	ExternalSnapshotName = snapshots.Key("external_snapshot_name").String()
 	ExternalEnabled = snapshots.Key("external_enabled").MustBool(true)
 	ExternalEnabled = snapshots.Key("external_enabled").MustBool(true)
 	SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true)
 	SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true)
-	SnapShotTTLDays = snapshots.Key("snapshot_TTL_days").MustInt(90)
 
 
 	// read dashboard settings
 	// read dashboard settings
 	dashboards := Cfg.Section("dashboards")
 	dashboards := Cfg.Section("dashboards")

+ 6 - 5
pkg/social/github_oauth.go

@@ -195,10 +195,9 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl
 func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
 func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
 
 
 	var data struct {
 	var data struct {
-		Id               int    `json:"id"`
-		Login            string `json:"login"`
-		Email            string `json:"email"`
-		OrganizationsUrl string `json:"organizations_url"`
+		Id    int    `json:"id"`
+		Login string `json:"login"`
+		Email string `json:"email"`
 	}
 	}
 
 
 	response, err := HttpGet(client, s.apiUrl)
 	response, err := HttpGet(client, s.apiUrl)
@@ -217,11 +216,13 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
 		Email: data.Email,
 		Email: data.Email,
 	}
 	}
 
 
+	organizationsUrl := fmt.Sprintf(s.apiUrl + "/orgs")
+
 	if !s.IsTeamMember(client) {
 	if !s.IsTeamMember(client) {
 		return nil, ErrMissingTeamMembership
 		return nil, ErrMissingTeamMembership
 	}
 	}
 
 
-	if !s.IsOrganizationMember(client, data.OrganizationsUrl) {
+	if !s.IsOrganizationMember(client, organizationsUrl) {
 		return nil, ErrMissingOrganizationMembership
 		return nil, ErrMissingOrganizationMembership
 	}
 	}
 
 

+ 1 - 1
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -26,7 +26,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
   loadStore() {
   loadStore() {
     const { nav, folder, view } = this.props;
     const { nav, folder, view } = this.props;
     return folder.load(view.routeParams.get('uid') as string).then(res => {
     return folder.load(view.routeParams.get('uid') as string).then(res => {
-      view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {});
+      view.updatePathAndQuery(`${res.url}/permissions`, {}, {});
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
     });
     });
   }
   }

+ 7 - 10
public/app/containers/ManageDashboards/FolderSettings.jest.tsx

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

+ 17 - 14
public/app/containers/ManageDashboards/FolderSettings.tsx

@@ -10,7 +10,6 @@ import appEvents from 'app/core/app_events';
 @observer
 @observer
 export class FolderSettings extends React.Component<IContainerProps, any> {
 export class FolderSettings extends React.Component<IContainerProps, any> {
   formSnapshot: any;
   formSnapshot: any;
-  dashboard: any;
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -22,9 +21,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
 
 
     return folder.load(view.routeParams.get('uid') as string).then(res => {
     return folder.load(view.routeParams.get('uid') as string).then(res => {
       this.formSnapshot = getSnapshot(folder);
       this.formSnapshot = getSnapshot(folder);
-      this.dashboard = res.dashboard;
-
-      view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {});
+      view.updatePathAndQuery(`${res.url}/settings`, {}, {});
 
 
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
     });
     });
@@ -51,7 +48,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     const { nav, folder, view } = this.props;
     const { nav, folder, view } = this.props;
 
 
     folder
     folder
-      .saveFolder(this.dashboard, { overwrite: false })
+      .saveFolder({ overwrite: false })
       .then(newUrl => {
       .then(newUrl => {
         view.updatePathAndQuery(newUrl, {}, {});
         view.updatePathAndQuery(newUrl, {}, {});
 
 
@@ -61,7 +58,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
       .then(() => {
       .then(() => {
         return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
         return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       })
       })
-      .catch(this.handleSaveFolderError);
+      .catch(this.handleSaveFolderError.bind(this));
   }
   }
 
 
   delete(evt) {
   delete(evt) {
@@ -79,7 +76,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       onConfirm: () => {
-        return this.props.folder.deleteFolder().then(() => {
+        return folder.deleteFolder().then(() => {
           appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
           appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
           view.updatePathAndQuery('dashboards', '', '');
           view.updatePathAndQuery('dashboards', '', '');
         });
         });
@@ -91,6 +88,8 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     if (err.data && err.data.status === 'version-mismatch') {
     if (err.data && err.data.status === 'version-mismatch') {
       err.isHandled = true;
       err.isHandled = true;
 
 
+      const { nav, folder, view } = this.props;
+
       appEvents.emit('confirm-modal', {
       appEvents.emit('confirm-modal', {
         title: 'Conflict',
         title: 'Conflict',
         text: 'Someone else has updated this folder.',
         text: 'Someone else has updated this folder.',
@@ -98,16 +97,20 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         onConfirm: () => {
-          this.props.folder.saveFolder(this.dashboard, { overwrite: true });
+          folder
+            .saveFolder({ overwrite: true })
+            .then(newUrl => {
+              view.updatePathAndQuery(newUrl, {}, {});
+
+              appEvents.emit('dashboard-saved');
+              appEvents.emit('alert-success', ['Folder saved']);
+            })
+            .then(() => {
+              return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
+            });
         },
         },
       });
       });
     }
     }
-
-    if (err.data && err.data.status === 'name-exists') {
-      err.isHandled = true;
-
-      appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
-    }
   }
   }
 
 
   render() {
   render() {

+ 3 - 3
public/app/core/components/Permissions/AddPermissions.jest.tsx

@@ -17,7 +17,7 @@ describe('AddPermissions', () => {
       ])
       ])
     );
     );
 
 
-    backendSrv.post = jest.fn();
+    backendSrv.post = jest.fn(() => Promise.resolve({}));
 
 
     store = RootStore.create(
     store = RootStore.create(
       {},
       {},
@@ -53,7 +53,7 @@ describe('AddPermissions', () => {
       wrapper.find('form').simulate('submit', { preventDefault() {} });
       wrapper.find('form').simulate('submit', { preventDefault() {} });
 
 
       expect(backendSrv.post.mock.calls.length).toBe(1);
       expect(backendSrv.post.mock.calls.length).toBe(1);
-      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
     });
     });
   });
   });
 
 
@@ -80,7 +80,7 @@ describe('AddPermissions', () => {
       wrapper.find('form').simulate('submit', { preventDefault() {} });
       wrapper.find('form').simulate('submit', { preventDefault() {} });
 
 
       expect(backendSrv.post.mock.calls.length).toBe(1);
       expect(backendSrv.post.mock.calls.length).toBe(1);
-      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
     });
     });
   });
   });
 
 

+ 0 - 8
public/app/core/components/Permissions/AddPermissions.tsx

@@ -135,14 +135,6 @@ class AddPermissions extends Component<IProps, any> {
             </div>
             </div>
           </div>
           </div>
         </form>
         </form>
-        {permissions.error ? (
-          <div className="gf-form width-17">
-            <span ng-if="ctrl.error" className="text-error p-l-1">
-              <i className="fa fa-warning" />
-              {permissions.error}
-            </span>
-          </div>
-        ) : null}
       </div>
       </div>
     );
     );
   }
   }

+ 0 - 4
public/app/core/components/help/help.ts

@@ -33,10 +33,6 @@ export class HelpCtrl {
         { keys: ['p', 's'], description: 'Open Panel Share Modal' },
         { keys: ['p', 's'], description: 'Open Panel Share Modal' },
         { keys: ['p', 'r'], description: 'Remove Panel' },
         { keys: ['p', 'r'], description: 'Remove Panel' },
       ],
       ],
-      'Focused Row': [
-        { keys: ['r', 'c'], description: 'Collapse Row' },
-        { keys: ['r', 'r'], description: 'Remove Row' },
-      ],
       'Time Range': [
       'Time Range': [
         { keys: ['t', 'z'], description: 'Zoom out time range' },
         { keys: ['t', 'z'], description: 'Zoom out time range' },
         {
         {

+ 5 - 40
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -78,8 +78,8 @@ export class ManageDashboardsCtrl {
           return;
           return;
         }
         }
 
 
-        return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => {
-          this.canSave = dash.meta.canSave;
+        return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
+          this.canSave = folder.canSave;
         });
         });
       });
       });
   }
   }
@@ -173,48 +173,13 @@ export class ManageDashboardsCtrl {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       onConfirm: () => {
-        const foldersAndDashboards = data.folders.concat(data.dashboards);
-        this.deleteFoldersAndDashboards(foldersAndDashboards);
+        this.deleteFoldersAndDashboards(data.folders, data.dashboards);
       },
       },
     });
     });
   }
   }
 
 
-  private deleteFoldersAndDashboards(uids) {
-    this.backendSrv.deleteDashboards(uids).then(result => {
-      const folders = _.filter(result, dash => dash.meta.isFolder);
-      const folderCount = folders.length;
-      const dashboards = _.filter(result, dash => !dash.meta.isFolder);
-      const dashCount = dashboards.length;
-
-      if (result.length > 0) {
-        let header;
-        let msg;
-
-        if (folderCount > 0 && dashCount > 0) {
-          header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
-          msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `;
-          msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
-        } else if (folderCount > 0) {
-          header = `Folder${folderCount === 1 ? '' : 's'} Deleted`;
-
-          if (folderCount === 1) {
-            msg = `${folders[0].dashboard.title} has been deleted`;
-          } else {
-            msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`;
-          }
-        } else if (dashCount > 0) {
-          header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
-
-          if (dashCount === 1) {
-            msg = `${dashboards[0].dashboard.title} has been deleted`;
-          } else {
-            msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
-          }
-        }
-
-        appEvents.emit('alert-success', [header, msg]);
-      }
-
+  private deleteFoldersAndDashboards(folderUids, dashboardUids) {
+    this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
       this.refreshList();
       this.refreshList();
     });
     });
   }
   }

+ 27 - 37
public/app/core/services/backend_srv.ts

@@ -221,14 +221,18 @@ export class BackendSrv {
     return this.get('/api/search', query);
     return this.get('/api/search', query);
   }
   }
 
 
-  getDashboard(type, slug) {
-    return this.get('/api/dashboards/' + type + '/' + slug);
+  getDashboardBySlug(slug) {
+    return this.get(`/api/dashboards/db/${slug}`);
   }
   }
 
 
   getDashboardByUid(uid: string) {
   getDashboardByUid(uid: string) {
     return this.get(`/api/dashboards/uid/${uid}`);
     return this.get(`/api/dashboards/uid/${uid}`);
   }
   }
 
 
+  getFolderByUid(uid: string) {
+    return this.get(`/api/folders/${uid}`);
+  }
+
   saveDashboard(dash, options) {
   saveDashboard(dash, options) {
     options = options || {};
     options = options || {};
 
 
@@ -240,55 +244,41 @@ export class BackendSrv {
     });
     });
   }
   }
 
 
-  createDashboardFolder(name) {
-    const dash = {
-      schemaVersion: 16,
-      title: name.trim(),
-      editable: true,
-      panels: [],
-    };
-
-    return this.post('/api/dashboards/db/', {
-      dashboard: dash,
-      isFolder: true,
-      overwrite: false,
-    }).then(res => {
-      return this.getDashboard('db', res.slug);
-    });
+  createFolder(payload: any) {
+    return this.post('/api/folders', payload);
   }
   }
 
 
-  saveFolder(dash, options) {
+  updateFolder(folder, options) {
     options = options || {};
     options = options || {};
 
 
-    return this.post('/api/dashboards/db/', {
-      dashboard: dash,
-      isFolder: true,
+    return this.put(`/api/folders/${folder.uid}`, {
+      title: folder.title,
+      version: folder.version,
       overwrite: options.overwrite === true,
       overwrite: options.overwrite === true,
-      message: options.message || '',
     });
     });
   }
   }
 
 
-  deleteDashboard(uid) {
-    let deferred = this.$q.defer();
+  deleteFolder(uid: string, showSuccessAlert) {
+    return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true });
+  }
 
 
-    this.getDashboardByUid(uid).then(fullDash => {
-      this.delete(`/api/dashboards/uid/${uid}`)
-        .then(() => {
-          deferred.resolve(fullDash);
-        })
-        .catch(err => {
-          deferred.reject(err);
-        });
+  deleteDashboard(uid, showSuccessAlert) {
+    return this.request({
+      method: 'DELETE',
+      url: `/api/dashboards/uid/${uid}`,
+      showSuccessAlert: showSuccessAlert === true,
     });
     });
-
-    return deferred.promise;
   }
   }
 
 
-  deleteDashboards(dashboardUids) {
+  deleteFoldersAndDashboards(folderUids, dashboardUids) {
     const tasks = [];
     const tasks = [];
 
 
-    for (let uid of dashboardUids) {
-      tasks.push(this.createTask(this.deleteDashboard.bind(this), true, uid));
+    for (let folderUid of folderUids) {
+      tasks.push(this.createTask(this.deleteFolder.bind(this), true, folderUid, true));
+    }
+
+    for (let dashboardUid of dashboardUids) {
+      tasks.push(this.createTask(this.deleteDashboard.bind(this), true, dashboardUid, true));
     }
     }
 
 
     return this.executeInOrder(tasks, []);
     return this.executeInOrder(tasks, []);

+ 5 - 26
public/app/core/services/keybindingSrv.ts

@@ -171,8 +171,9 @@ export class KeybindingSrv {
     // delete panel
     // delete panel
     this.bind('p r', () => {
     this.bind('p r', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
-        var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
-        panelInfo.row.removePanel(panelInfo.panel);
+        this.$rootScope.appEvent('panel-remove', {
+          panelId: dashboard.meta.focusPanelId,
+        });
         dashboard.meta.focusPanelId = 0;
         dashboard.meta.focusPanelId = 0;
       }
       }
     });
     });
@@ -192,36 +193,14 @@ export class KeybindingSrv {
       }
       }
     });
     });
 
 
-    // delete row
-    this.bind('r r', () => {
-      if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
-        var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
-        dashboard.removeRow(panelInfo.row);
-        dashboard.meta.focusPanelId = 0;
-      }
-    });
-
-    // collapse row
-    this.bind('r c', () => {
-      if (dashboard.meta.focusPanelId) {
-        var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
-        panelInfo.row.toggleCollapse();
-        dashboard.meta.focusPanelId = 0;
-      }
-    });
-
     // collapse all rows
     // collapse all rows
     this.bind('d shift+c', () => {
     this.bind('d shift+c', () => {
-      for (let row of dashboard.rows) {
-        row.collapse = true;
-      }
+      dashboard.collapseRows();
     });
     });
 
 
     // expand all rows
     // expand all rows
     this.bind('d shift+e', () => {
     this.bind('d shift+e', () => {
-      for (let row of dashboard.rows) {
-        row.collapse = false;
-      }
+      dashboard.expandRows();
     });
     });
 
 
     this.bind('d n', e => {
     this.bind('d n', e => {

+ 21 - 0
public/app/core/utils/kbn.ts

@@ -463,6 +463,15 @@ kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2);
 kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
 kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
 kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
 kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
 
 
+// Hash Rate
+kbn.valueFormats.Hs     = kbn.formatBuilders.decimalSIPrefix('H/s');
+kbn.valueFormats.KHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 1);
+kbn.valueFormats.MHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 2);
+kbn.valueFormats.GHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 3);
+kbn.valueFormats.THs    = kbn.formatBuilders.decimalSIPrefix('H/s', 4);
+kbn.valueFormats.PHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 5);
+kbn.valueFormats.EHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 6);
+
 // Throughput
 // Throughput
 kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops');
 kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops');
 kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps');
 kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps');
@@ -878,6 +887,18 @@ kbn.getUnitFormats = function() {
         { text: 'gigabits/sec', value: 'Gbits' },
         { text: 'gigabits/sec', value: 'Gbits' },
       ],
       ],
     },
     },
+    {
+      text: 'hash rate',
+      submenu: [
+        {text: 'hashes/sec', value: 'Hs'},
+        {text: 'kilohashes/sec',    value: 'KHs'},
+        {text: 'megahashes/sec',   value: 'MHs'},
+        {text: 'gigahashes/sec', value: 'GHs'},
+        {text: 'terahashes/sec',    value: 'THs'},
+        {text: 'petahashes/sec', value: 'PHs'},
+        {text: 'exahashes/sec',    value: 'EHs'},
+      ],
+    },
     {
     {
       text: 'throughput',
       text: 'throughput',
       submenu: [
       submenu: [

+ 2 - 2
public/app/features/dashboard/create_folder_ctrl.ts

@@ -18,9 +18,9 @@ export class CreateFolderCtrl {
       return;
       return;
     }
     }
 
 
-    return this.backendSrv.createDashboardFolder(this.title).then(result => {
+    return this.backendSrv.createFolder({ title: this.title }).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
-      this.$location.url(locationUtil.stripBaseFromUrl(result.meta.url));
+      this.$location.url(locationUtil.stripBaseFromUrl(result.url));
     });
     });
   }
   }
 
 

+ 39 - 0
public/app/features/dashboard/dashboard_ctrl.ts

@@ -3,6 +3,7 @@ import config from 'app/core/config';
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import { PanelContainer } from './dashgrid/PanelContainer';
 import { PanelContainer } from './dashgrid/PanelContainer';
 import { DashboardModel } from './dashboard_model';
 import { DashboardModel } from './dashboard_model';
+import { PanelModel } from './panel_model';
 
 
 export class DashboardCtrl implements PanelContainer {
 export class DashboardCtrl implements PanelContainer {
   dashboard: DashboardModel;
   dashboard: DashboardModel;
@@ -130,9 +131,47 @@ export class DashboardCtrl implements PanelContainer {
     return this;
     return this;
   }
   }
 
 
+  onRemovingPanel(evt, options) {
+    options = options || {};
+    if (!options.panelId) {
+      return;
+    }
+
+    var panelInfo = this.dashboard.getPanelInfoById(options.panelId);
+    this.removePanel(panelInfo.panel, true);
+  }
+
+  removePanel(panel: PanelModel, ask: boolean) {
+    // confirm deletion
+    if (ask !== false) {
+      var text2, confirmText;
+
+      if (panel.alert) {
+        text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
+        confirmText = 'YES';
+      }
+
+      this.$scope.appEvent('confirm-modal', {
+        title: 'Remove Panel',
+        text: 'Are you sure you want to remove this panel?',
+        text2: text2,
+        icon: 'fa-trash',
+        confirmText: confirmText,
+        yesText: 'Remove',
+        onConfirm: () => {
+          this.removePanel(panel, false);
+        },
+      });
+      return;
+    }
+
+    this.dashboard.removePanel(panel);
+  }
+
   init(dashboard) {
   init(dashboard) {
     this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
     this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
     this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
     this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
+    this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
     this.setupDashboard(dashboard);
     this.setupDashboard(dashboard);
   }
   }
 }
 }

+ 28 - 0
public/app/features/dashboard/dashboard_model.ts

@@ -524,6 +524,34 @@ export class DashboardModel {
     this.removePanel(row);
     this.removePanel(row);
   }
   }
 
 
+  expandRows() {
+    for (let i = 0; i < this.panels.length; i++) {
+      var panel = this.panels[i];
+
+      if (panel.type !== 'row') {
+        continue;
+      }
+
+      if (panel.collapsed) {
+        this.toggleRow(panel);
+      }
+    }
+  }
+
+  collapseRows() {
+    for (let i = 0; i < this.panels.length; i++) {
+      var panel = this.panels[i];
+
+      if (panel.type !== 'row') {
+        continue;
+      }
+
+      if (!panel.collapsed) {
+        this.toggleRow(panel);
+      }
+    }
+  }
+
   setPanelFocus(id) {
   setPanelFocus(id) {
     this.meta.focusPanelId = id;
     this.meta.focusPanelId = id;
   }
   }

+ 1 - 1
public/app/features/dashboard/dashboard_srv.ts

@@ -92,7 +92,7 @@ export class DashboardSrv {
 
 
   save(clone, options) {
   save(clone, options) {
     options = options || {};
     options = options || {};
-    options.folderId = options.folderId || this.dash.meta.folderId || clone.folderId;
+    options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
 
 
     return this.backendSrv
     return this.backendSrv
       .saveDashboard(clone, options)
       .saveDashboard(clone, options)

+ 6 - 4
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
 import React from 'react';
-import ReactGridLayout from 'react-grid-layout';
+import ReactGridLayout from 'react-grid-layout-grafana';
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
 import { DashboardPanel } from './DashboardPanel';
 import { DashboardPanel } from './DashboardPanel';
 import { DashboardModel } from '../dashboard_model';
 import { DashboardModel } from '../dashboard_model';
@@ -50,7 +50,8 @@ function GridWrapper({
       onResize={onResize}
       onResize={onResize}
       onResizeStop={onResizeStop}
       onResizeStop={onResizeStop}
       onDragStop={onDragStop}
       onDragStop={onDragStop}
-      onLayoutChange={onLayoutChange}>
+      onLayoutChange={onLayoutChange}
+    >
       {children}
       {children}
     </ReactGridLayout>
     </ReactGridLayout>
   );
   );
@@ -178,7 +179,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
       panelElements.push(
       panelElements.push(
         <div key={panel.id.toString()} className={panelClasses}>
         <div key={panel.id.toString()} className={panelClasses}>
           <DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
           <DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
-        </div>,
+        </div>
       );
       );
     }
     }
 
 
@@ -196,7 +197,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
         onWidthChange={this.onWidthChange}
         onWidthChange={this.onWidthChange}
         onDragStop={this.onDragStop}
         onDragStop={this.onDragStop}
         onResize={this.onResize}
         onResize={this.onResize}
-        onResizeStop={this.onResizeStop}>
+        onResizeStop={this.onResizeStop}
+      >
         {this.renderPanels()}
         {this.renderPanels()}
       </SizedReactLayoutGrid>
       </SizedReactLayoutGrid>
     );
     );

+ 1 - 1
public/app/features/dashboard/folder_dashboards_ctrl.ts

@@ -14,7 +14,7 @@ export class FolderDashboardsCtrl {
       const loader = new FolderPageLoader(this.backendSrv);
       const loader = new FolderPageLoader(this.backendSrv);
 
 
       loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
       loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
-        const url = locationUtil.stripBaseFromUrl(folder.meta.url);
+        const url = locationUtil.stripBaseFromUrl(folder.url);
 
 
         if (url !== $location.path()) {
         if (url !== $location.path()) {
           $location.path(url).replace();
           $location.path(url).replace();

+ 6 - 6
public/app/features/dashboard/folder_page_loader.ts

@@ -36,16 +36,16 @@ export class FolderPageLoader {
       },
       },
     };
     };
 
 
-    return this.backendSrv.getDashboardByUid(uid).then(result => {
-      ctrl.folderId = result.dashboard.id;
-      const folderTitle = result.dashboard.title;
-      const folderUrl = result.meta.url;
+    return this.backendSrv.getFolderByUid(uid).then(folder => {
+      ctrl.folderId = folder.id;
+      const folderTitle = folder.title;
+      const folderUrl = folder.url;
       ctrl.navModel.main.text = folderTitle;
       ctrl.navModel.main.text = folderTitle;
 
 
       const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
       const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
       dashTab.url = folderUrl;
       dashTab.url = folderUrl;
 
 
-      if (result.meta.canAdmin) {
+      if (folder.canAdmin) {
         const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
         const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
         permTab.url = folderUrl + '/permissions';
         permTab.url = folderUrl + '/permissions';
 
 
@@ -55,7 +55,7 @@ export class FolderPageLoader {
         ctrl.navModel.main.children = [dashTab];
         ctrl.navModel.main.children = [dashTab];
       }
       }
 
 
-      return result;
+      return folder;
     });
     });
   }
   }
 }
 }

+ 3 - 3
public/app/features/dashboard/folder_picker/folder_picker.ts

@@ -89,13 +89,13 @@ export class FolderPickerCtrl {
       evt.preventDefault();
       evt.preventDefault();
     }
     }
 
 
-    return this.backendSrv.createDashboardFolder(this.newFolderName).then(result => {
+    return this.backendSrv.createFolder({ title: this.newFolderName }).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
 
 
       this.closeCreateFolder();
       this.closeCreateFolder();
       this.folder = {
       this.folder = {
-        text: result.dashboard.title,
-        value: result.dashboard.id,
+        text: result.title,
+        value: result.id,
       };
       };
       this.onFolderChange(this.folder);
       this.onFolderChange(this.folder);
     });
     });

+ 10 - 18
public/app/features/dashboard/folder_settings_ctrl.ts

@@ -7,8 +7,7 @@ export class FolderSettingsCtrl {
   folderId: number;
   folderId: number;
   uid: string;
   uid: string;
   canSave = false;
   canSave = false;
-  dashboard: any;
-  meta: any;
+  folder: any;
   title: string;
   title: string;
   hasChanged: boolean;
   hasChanged: boolean;
 
 
@@ -23,10 +22,9 @@ export class FolderSettingsCtrl {
           $location.path(`${folder.meta.url}/settings`).replace();
           $location.path(`${folder.meta.url}/settings`).replace();
         }
         }
 
 
-        this.dashboard = folder.dashboard;
-        this.meta = folder.meta;
-        this.canSave = folder.meta.canSave;
-        this.title = this.dashboard.title;
+        this.folder = folder;
+        this.canSave = this.folder.canSave;
+        this.title = this.folder.title;
       });
       });
     }
     }
   }
   }
@@ -38,10 +36,10 @@ export class FolderSettingsCtrl {
       return;
       return;
     }
     }
 
 
-    this.dashboard.title = this.title.trim();
+    this.folder.title = this.title.trim();
 
 
     return this.backendSrv
     return this.backendSrv
-      .updateDashboardFolder(this.dashboard, { overwrite: false })
+      .updateFolder(this.folder)
       .then(result => {
       .then(result => {
         if (result.url !== this.$location.path()) {
         if (result.url !== this.$location.path()) {
           this.$location.url(result.url + '/settings');
           this.$location.url(result.url + '/settings');
@@ -54,7 +52,7 @@ export class FolderSettingsCtrl {
   }
   }
 
 
   titleChanged() {
   titleChanged() {
-    this.hasChanged = this.dashboard.title.toLowerCase() !== this.title.trim().toLowerCase();
+    this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase();
   }
   }
 
 
   delete(evt) {
   delete(evt) {
@@ -69,8 +67,8 @@ export class FolderSettingsCtrl {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       onConfirm: () => {
-        return this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
-          appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]);
+        return this.backendSrv.deleteFolder(this.uid).then(() => {
+          appEvents.emit('alert-success', ['Folder Deleted', `${this.folder.title} has been deleted`]);
           this.$location.url('dashboards');
           this.$location.url('dashboards');
         });
         });
       },
       },
@@ -88,15 +86,9 @@ export class FolderSettingsCtrl {
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         onConfirm: () => {
-          this.backendSrv.updateDashboardFolder(this.dashboard, { overwrite: true });
+          this.backendSrv.updateFolder(this.folder, { overwrite: true });
         },
         },
       });
       });
     }
     }
-
-    if (err.data && err.data.status === 'name-exists') {
-      err.isHandled = true;
-
-      appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
-    }
   }
   }
 }
 }

+ 12 - 1
public/app/features/dashboard/save_as_modal.ts

@@ -22,6 +22,8 @@ const template = `
       <div class="gf-form">
       <div class="gf-form">
         <folder-picker initial-folder-id="ctrl.folderId"
         <folder-picker initial-folder-id="ctrl.folderId"
                        on-change="ctrl.onFolderChange($folder)"
                        on-change="ctrl.onFolderChange($folder)"
+                       enter-folder-creation="ctrl.onEnterFolderCreation()"
+                       exit-folder-creation="ctrl.onExitFolderCreation()"
                        enable-create-new="true"
                        enable-create-new="true"
                        label-class="width-7">
                        label-class="width-7">
         </folder-picker>
         </folder-picker>
@@ -29,7 +31,7 @@ const template = `
 		</div>
 		</div>
 
 
 		<div class="gf-form-button-row text-center">
 		<div class="gf-form-button-row text-center">
-			<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
+			<button type="submit" class="btn btn-success" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
 			<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
 			<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
 		</div>
 		</div>
 	</form>
 	</form>
@@ -40,6 +42,7 @@ export class SaveDashboardAsModalCtrl {
   clone: any;
   clone: any;
   folderId: any;
   folderId: any;
   dismiss: () => void;
   dismiss: () => void;
+  isValidFolderSelection = true;
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private dashboardSrv) {
   constructor(private dashboardSrv) {
@@ -79,6 +82,14 @@ export class SaveDashboardAsModalCtrl {
   onFolderChange(folder) {
   onFolderChange(folder) {
     this.folderId = folder.id;
     this.folderId = folder.id;
   }
   }
+
+  onEnterFolderCreation() {
+    this.isValidFolderSelection = false;
+  }
+
+  onExitFolderCreation() {
+    this.isValidFolderSelection = true;
+  }
 }
 }
 
 
 export function saveDashboardAsDirective() {
 export function saveDashboardAsDirective() {

+ 1 - 1
public/app/features/dashboard/specs/dashboard_migration.jest.ts

@@ -374,7 +374,7 @@ describe('DashboardModel', function() {
 
 
     it('should assign id', function() {
     it('should assign id', function() {
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
-      model.rows[0].panels[0] = { };
+      model.rows[0].panels[0] = {};
 
 
       let dashboard = new DashboardModel(model);
       let dashboard = new DashboardModel(model);
       expect(dashboard.panels[0].id).toBe(1);
       expect(dashboard.panels[0].id).toBe(1);

+ 4 - 25
public/app/features/panel/panel_ctrl.ts

@@ -241,31 +241,10 @@ export class PanelCtrl {
     });
     });
   }
   }
 
 
-  removePanel(ask: boolean) {
-    // confirm deletion
-    if (ask !== false) {
-      var text2, confirmText;
-
-      if (this.panel.alert) {
-        text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
-        confirmText = 'YES';
-      }
-
-      appEvents.emit('confirm-modal', {
-        title: 'Remove Panel',
-        text: 'Are you sure you want to remove this panel?',
-        text2: text2,
-        icon: 'fa-trash',
-        confirmText: confirmText,
-        yesText: 'Remove',
-        onConfirm: () => {
-          this.removePanel(false);
-        },
-      });
-      return;
-    }
-
-    this.dashboard.removePanel(this.panel);
+  removePanel() {
+    this.publishAppEvent('panel-remove', {
+      panelId: this.panel.id,
+    });
   }
   }
 
 
   editPanelJson() {
   editPanelJson() {

+ 1 - 1
public/app/features/panel/solo_panel_ctrl.ts

@@ -18,7 +18,7 @@ export class SoloPanelCtrl {
 
 
       // if no uid, redirect to new route based on slug
       // if no uid, redirect to new route based on slug
       if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
       if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
-        backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
+        backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
           if (res) {
           if (res) {
             const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
             const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
             $location.path(url).replace();
             $location.path(url).replace();

+ 78 - 2
public/app/features/templating/specs/template_srv.jest.ts

@@ -31,12 +31,40 @@ describe('templateSrv', function() {
       expect(target).toBe('this.mupp.filters');
       expect(target).toBe('this.mupp.filters');
     });
     });
 
 
+    it('should replace ${test} with scoped value', function() {
+      var target = _templateSrv.replace('this.${test}.filters', {
+        test: { value: 'mupp', text: 'asd' },
+      });
+      expect(target).toBe('this.mupp.filters');
+    });
+
+    it('should replace ${test:glob} with scoped value', function() {
+      var target = _templateSrv.replace('this.${test:glob}.filters', {
+        test: { value: 'mupp', text: 'asd' },
+      });
+      expect(target).toBe('this.mupp.filters');
+    });
+
     it('should replace $test with scoped text', function() {
     it('should replace $test with scoped text', function() {
       var target = _templateSrv.replaceWithText('this.$test.filters', {
       var target = _templateSrv.replaceWithText('this.$test.filters', {
         test: { value: 'mupp', text: 'asd' },
         test: { value: 'mupp', text: 'asd' },
       });
       });
       expect(target).toBe('this.asd.filters');
       expect(target).toBe('this.asd.filters');
     });
     });
+
+    it('should replace ${test} with scoped text', function() {
+      var target = _templateSrv.replaceWithText('this.${test}.filters', {
+        test: { value: 'mupp', text: 'asd' },
+      });
+      expect(target).toBe('this.asd.filters');
+    });
+
+    it('should replace ${test:glob} with scoped text', function() {
+      var target = _templateSrv.replaceWithText('this.${test:glob}.filters', {
+        test: { value: 'mupp', text: 'asd' },
+      });
+      expect(target).toBe('this.asd.filters');
+    });
   });
   });
 
 
   describe('getAdhocFilters', function() {
   describe('getAdhocFilters', function() {
@@ -79,18 +107,34 @@ describe('templateSrv', function() {
       ]);
       ]);
     });
     });
 
 
+
     it('should replace $test with globbed value', function() {
     it('should replace $test with globbed value', function() {
       var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
       expect(target).toBe('this.{value1,value2}.filters');
     });
     });
 
 
+    it('should replace ${test} with globbed value', function() {
+      var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
+      expect(target).toBe('this.{value1,value2}.filters');
+    });
+
+    it('should replace ${test:glob} with globbed value', function() {
+      var target = _templateSrv.replace('this.${test:glob}.filters', {});
+      expect(target).toBe('this.{value1,value2}.filters');
+    });
+
     it('should replace $test with piped value', function() {
     it('should replace $test with piped value', function() {
       var target = _templateSrv.replace('this=$test', {}, 'pipe');
       var target = _templateSrv.replace('this=$test', {}, 'pipe');
       expect(target).toBe('this=value1|value2');
       expect(target).toBe('this=value1|value2');
     });
     });
 
 
-    it('should replace $test with piped value', function() {
-      var target = _templateSrv.replace('this=$test', {}, 'pipe');
+    it('should replace ${test} with piped value', function() {
+      var target = _templateSrv.replace('this=${test}', {}, 'pipe');
+      expect(target).toBe('this=value1|value2');
+    });
+
+    it('should replace ${test:pipe} with piped value', function() {
+      var target = _templateSrv.replace('this=${test:pipe}', {});
       expect(target).toBe('this=value1|value2');
       expect(target).toBe('this=value1|value2');
     });
     });
   });
   });
@@ -111,6 +155,16 @@ describe('templateSrv', function() {
       var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
       expect(target).toBe('this.{value1,value2}.filters');
     });
     });
+
+    it('should replace ${test} with formatted all value', function() {
+      var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
+      expect(target).toBe('this.{value1,value2}.filters');
+    });
+
+    it('should replace ${test:glob} with formatted all value', function() {
+      var target = _templateSrv.replace('this.${test:glob}.filters', {});
+      expect(target).toBe('this.{value1,value2}.filters');
+    });
   });
   });
 
 
   describe('variable with all option and custom value', function() {
   describe('variable with all option and custom value', function() {
@@ -131,6 +185,16 @@ describe('templateSrv', function() {
       expect(target).toBe('this.*.filters');
       expect(target).toBe('this.*.filters');
     });
     });
 
 
+    it('should replace ${test} with formatted all value', function() {
+      var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
+      expect(target).toBe('this.*.filters');
+    });
+
+    it('should replace ${test:glob} with formatted all value', function() {
+      var target = _templateSrv.replace('this.${test:glob}.filters', {});
+      expect(target).toBe('this.*.filters');
+    });
+
     it('should not escape custom all value', function() {
     it('should not escape custom all value', function() {
       var target = _templateSrv.replace('this.$test', {}, 'regex');
       var target = _templateSrv.replace('this.$test', {}, 'regex');
       expect(target).toBe('this.*');
       expect(target).toBe('this.*');
@@ -143,6 +207,18 @@ describe('templateSrv', function() {
       var target = _templateSrv.replace('this:$test', {}, 'lucene');
       var target = _templateSrv.replace('this:$test', {}, 'lucene');
       expect(target).toBe('this:value\\/4');
       expect(target).toBe('this:value\\/4');
     });
     });
+
+    it('should properly escape ${test} with lucene escape sequences', function() {
+      initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
+      var target = _templateSrv.replace('this:${test}', {}, 'lucene');
+      expect(target).toBe('this:value\\/4');
+    });
+
+    it('should properly escape ${test:lucene} with lucene escape sequences', function() {
+      initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
+      var target = _templateSrv.replace('this:${test:lucene}', {});
+      expect(target).toBe('this:value\\/4');
+    });
   });
   });
 
 
   describe('format variable to string values', function() {
   describe('format variable to string values', function() {

+ 19 - 10
public/app/features/templating/template_srv.ts

@@ -8,7 +8,13 @@ function luceneEscape(value) {
 export class TemplateSrv {
 export class TemplateSrv {
   variables: any[];
   variables: any[];
 
 
-  private regex = /\$(\w+)|\[\[([\s\S]+?)\]\]/g;
+  /*
+   * This regex matches 3 types of variable reference with an optional format specifier
+   * \$(\w+)                          $var1
+   * \[\[([\s\S]+?)(?::(\w+))?\]\]    [[var2]] or [[var2:fmt2]]
+   * \${(\w+)(?::(\w+))?}             ${var3} or ${var3:fmt3}
+   */
+  private regex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g;
   private index = {};
   private index = {};
   private grafanaVariables = {};
   private grafanaVariables = {};
   private builtIns = {};
   private builtIns = {};
@@ -89,6 +95,9 @@ export class TemplateSrv {
         }
         }
 
 
         var escapedValues = _.map(value, kbn.regexEscape);
         var escapedValues = _.map(value, kbn.regexEscape);
+        if (escapedValues.length === 1) {
+          return escapedValues[0];
+        }
         return '(' + escapedValues.join('|') + ')';
         return '(' + escapedValues.join('|') + ')';
       }
       }
       case 'lucene': {
       case 'lucene': {
@@ -140,8 +149,8 @@ export class TemplateSrv {
 
 
     str = _.escape(str);
     str = _.escape(str);
     this.regex.lastIndex = 0;
     this.regex.lastIndex = 0;
-    return str.replace(this.regex, (match, g1, g2) => {
-      if (this.index[g1 || g2] || this.builtIns[g1 || g2]) {
+    return str.replace(this.regex, (match, var1, var2, fmt2, var3) => {
+      if (this.index[var1 || var2 || var3] || this.builtIns[var1 || var2 || var3]) {
         return '<span class="template-variable">' + match + '</span>';
         return '<span class="template-variable">' + match + '</span>';
       }
       }
       return match;
       return match;
@@ -167,11 +176,11 @@ export class TemplateSrv {
     var variable, systemValue, value;
     var variable, systemValue, value;
     this.regex.lastIndex = 0;
     this.regex.lastIndex = 0;
 
 
-    return target.replace(this.regex, (match, g1, g2) => {
-      variable = this.index[g1 || g2];
-
+    return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
+      variable = this.index[var1 || var2 || var3];
+      format = fmt2 || fmt3 || format;
       if (scopedVars) {
       if (scopedVars) {
-        value = scopedVars[g1 || g2];
+        value = scopedVars[var1 || var2 || var3];
         if (value) {
         if (value) {
           return this.formatValue(value.value, format, variable);
           return this.formatValue(value.value, format, variable);
         }
         }
@@ -212,15 +221,15 @@ export class TemplateSrv {
     var variable;
     var variable;
     this.regex.lastIndex = 0;
     this.regex.lastIndex = 0;
 
 
-    return target.replace(this.regex, (match, g1, g2) => {
+    return target.replace(this.regex, (match, var1, var2, fmt2, var3) => {
       if (scopedVars) {
       if (scopedVars) {
-        var option = scopedVars[g1 || g2];
+        var option = scopedVars[var1 || var2 || var3];
         if (option) {
         if (option) {
           return option.text;
           return option.text;
         }
         }
       }
       }
 
 
-      variable = this.index[g1 || g2];
+      variable = this.index[var1 || var2 || var3];
       if (!variable) {
       if (!variable) {
         return match;
         return match;
       }
       }

+ 1 - 1
public/app/partials/login.html

@@ -59,7 +59,7 @@
           Sign in with {{oauth.generic_oauth.name}}
           Sign in with {{oauth.generic_oauth.name}}
         </a>
         </a>
       </div>
       </div>
-      <div class="login-signup-box">
+      <div class="login-signup-box" ng-show="!disableUserSignUp">
         <div class="login-signup-title p-r-1">
         <div class="login-signup-title p-r-1">
           New to Grafana?
           New to Grafana?
         </div>
         </div>

+ 1 - 0
public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html

@@ -101,6 +101,7 @@
 												get-options="getSizeOptions()"
 												get-options="getSizeOptions()"
 												on-change="onChangeInternal()"
 												on-change="onChangeInternal()"
 												label-mode="true"
 												label-mode="true"
+                        allow-custom="true"
 												css-class="width-12">
 												css-class="width-12">
 			</gf-form-dropdown>
 			</gf-form-dropdown>
 		</div>
 		</div>

+ 4 - 0
public/app/plugins/datasource/graphite/query_ctrl.ts

@@ -348,6 +348,10 @@ export class GraphiteQueryCtrl extends QueryCtrl {
     let tagKey = tag.key;
     let tagKey = tag.key;
     return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then(values => {
     return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then(values => {
       let altValues = _.map(values, 'text');
       let altValues = _.map(values, 'text');
+      // Add template variables as additional values
+      _.eachRight(this.templateSrv.variables, variable => {
+        altValues.push('${' + variable.name + ':regex}');
+      });
       return mapToDropdownOptions(altValues);
       return mapToDropdownOptions(altValues);
     });
     });
   }
   }

+ 1 - 1
public/app/routes/dashboard_loaders.ts

@@ -21,7 +21,7 @@ export class LoadDashboardCtrl {
 
 
     // if no uid, redirect to new route based on slug
     // if no uid, redirect to new route based on slug
     if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
     if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
-      backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
+      backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
         if (res) {
         if (res) {
           $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
           $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
         }
         }

+ 14 - 12
public/app/stores/FolderStore/FolderStore.ts

@@ -2,11 +2,12 @@ import { types, getEnv, flow } from 'mobx-state-tree';
 
 
 export const Folder = types.model('Folder', {
 export const Folder = types.model('Folder', {
   id: types.identifier(types.number),
   id: types.identifier(types.number),
+  uid: types.string,
   title: types.string,
   title: types.string,
   url: types.string,
   url: types.string,
   canSave: types.boolean,
   canSave: types.boolean,
-  uid: types.string,
   hasChanged: types.boolean,
   hasChanged: types.boolean,
+  version: types.number,
 });
 });
 
 
 export const FolderStore = types
 export const FolderStore = types
@@ -21,15 +22,15 @@ export const FolderStore = types
       }
       }
 
 
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
-      const res = yield backendSrv.getDashboardByUid(uid);
-
+      const res = yield backendSrv.getFolderByUid(uid);
       self.folder = Folder.create({
       self.folder = Folder.create({
-        id: res.dashboard.id,
-        title: res.dashboard.title,
-        url: res.meta.url,
-        uid: res.dashboard.uid,
-        canSave: res.meta.canSave,
+        id: res.id,
+        uid: res.uid,
+        title: res.title,
+        url: res.url,
+        canSave: res.canSave,
         hasChanged: false,
         hasChanged: false,
+        version: res.version,
       });
       });
 
 
       return res;
       return res;
@@ -40,12 +41,13 @@ export const FolderStore = types
       self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0;
       self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0;
     },
     },
 
 
-    saveFolder: flow(function* saveFolder(dashboard: any, options: any) {
+    saveFolder: flow(function* saveFolder(options: any) {
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
-      dashboard.title = self.folder.title.trim();
+      self.folder.title = self.folder.title.trim();
 
 
-      const res = yield backendSrv.saveFolder(dashboard, options);
+      const res = yield backendSrv.updateFolder(self.folder, options);
       self.folder.url = res.url;
       self.folder.url = res.url;
+      self.folder.version = res.version;
 
 
       return `${self.folder.url}/settings`;
       return `${self.folder.url}/settings`;
     }),
     }),
@@ -53,6 +55,6 @@ export const FolderStore = types
     deleteFolder: flow(function* deleteFolder() {
     deleteFolder: flow(function* deleteFolder() {
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
 
 
-      return backendSrv.deleteDashboard(self.folder.uid);
+      return backendSrv.deleteFolder(self.folder.uid);
     }),
     }),
   }));
   }));

+ 12 - 67
public/app/stores/PermissionsStore/PermissionsStore.jest.ts

@@ -1,10 +1,10 @@
-import { PermissionsStore, aclTypeValues } from './PermissionsStore';
+import { PermissionsStore } from './PermissionsStore';
 import { backendSrv } from 'test/mocks/common';
 import { backendSrv } from 'test/mocks/common';
 
 
 describe('PermissionsStore', () => {
 describe('PermissionsStore', () => {
   let store;
   let store;
 
 
-  beforeEach(() => {
+  beforeEach(async () => {
     backendSrv.get.mockReturnValue(
     backendSrv.get.mockReturnValue(
       Promise.resolve([
       Promise.resolve([
         { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
         { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
@@ -20,7 +20,7 @@ describe('PermissionsStore', () => {
       ])
       ])
     );
     );
 
 
-    backendSrv.post = jest.fn();
+    backendSrv.post = jest.fn(() => Promise.resolve({}));
 
 
     store = PermissionsStore.create(
     store = PermissionsStore.create(
       {
       {
@@ -32,84 +32,33 @@ describe('PermissionsStore', () => {
       }
       }
     );
     );
 
 
-    return store.load(1, false, false);
+    await store.load(1, false, false);
   });
   });
 
 
-  it('should save update on permission change', () => {
+  it('should save update on permission change', async () => {
     expect(store.items[0].permission).toBe(1);
     expect(store.items[0].permission).toBe(1);
     expect(store.items[0].permissionName).toBe('View');
     expect(store.items[0].permissionName).toBe('View');
 
 
-    store.updatePermissionOnIndex(0, 2, 'Edit');
+    await store.updatePermissionOnIndex(0, 2, 'Edit');
 
 
     expect(store.items[0].permission).toBe(2);
     expect(store.items[0].permission).toBe(2);
     expect(store.items[0].permissionName).toBe('Edit');
     expect(store.items[0].permissionName).toBe('Edit');
     expect(backendSrv.post.mock.calls.length).toBe(1);
     expect(backendSrv.post.mock.calls.length).toBe(1);
-    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
   });
   });
 
 
-  it('should save removed permissions automatically', () => {
+  it('should save removed permissions automatically', async () => {
     expect(store.items.length).toBe(3);
     expect(store.items.length).toBe(3);
 
 
-    store.removeStoreItem(2);
+    await store.removeStoreItem(2);
 
 
     expect(store.items.length).toBe(2);
     expect(store.items.length).toBe(2);
     expect(backendSrv.post.mock.calls.length).toBe(1);
     expect(backendSrv.post.mock.calls.length).toBe(1);
-    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-  });
-
-  describe('when duplicate team permissions are added', () => {
-    beforeEach(() => {
-      const newItem = {
-        teamId: 10,
-        team: 'tester-team',
-        permission: 1,
-        dashboardId: 1,
-      };
-      store.resetNewType();
-      store.newItem.setTeam(newItem.teamId, newItem.team);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-
-      store.newItem.setTeam(newItem.teamId, newItem.team);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-    });
-
-    it('should return a validation error', () => {
-      expect(store.items.length).toBe(4);
-      expect(store.error).toBe('This permission exists already.');
-      expect(backendSrv.post.mock.calls.length).toBe(1);
-    });
-  });
-
-  describe('when duplicate user permissions are added', () => {
-    beforeEach(() => {
-      expect(store.items.length).toBe(3);
-      const newItem = {
-        userId: 10,
-        userLogin: 'tester1',
-        permission: 1,
-        dashboardId: 1,
-      };
-      store.setNewType(aclTypeValues.USER.value);
-      store.newItem.setUser(newItem.userId, newItem.userLogin);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-      store.setNewType(aclTypeValues.USER.value);
-      store.newItem.setUser(newItem.userId, newItem.userLogin);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-    });
-
-    it('should return a validation error', () => {
-      expect(store.items.length).toBe(4);
-      expect(store.error).toBe('This permission exists already.');
-      expect(backendSrv.post.mock.calls.length).toBe(1);
-    });
+    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
   });
   });
 
 
   describe('when one inherited and one not inherited team permission are added', () => {
   describe('when one inherited and one not inherited team permission are added', () => {
-    beforeEach(() => {
+    beforeEach(async () => {
       const overridingItemForChildDashboard = {
       const overridingItemForChildDashboard = {
         team: 'MyTestTeam',
         team: 'MyTestTeam',
         dashboardId: 1,
         dashboardId: 1,
@@ -120,11 +69,7 @@ describe('PermissionsStore', () => {
       store.resetNewType();
       store.resetNewType();
       store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team);
       store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team);
       store.newItem.setPermission(overridingItemForChildDashboard.permission);
       store.newItem.setPermission(overridingItemForChildDashboard.permission);
-      store.addStoreItem();
-    });
-
-    it('should allowing overriding the inherited permission and not throw a validation error', () => {
-      expect(store.error).toBe(null);
+      await store.addStoreItem();
     });
     });
 
 
     it('should add new overriding permission', () => {
     it('should add new overriding permission', () => {

+ 19 - 34
public/app/stores/PermissionsStore/PermissionsStore.ts

@@ -1,8 +1,6 @@
 import { types, getEnv, flow } from 'mobx-state-tree';
 import { types, getEnv, flow } from 'mobx-state-tree';
 import { PermissionsStoreItem } from './PermissionsStoreItem';
 import { PermissionsStoreItem } from './PermissionsStoreItem';
 
 
-const duplicateError = 'This permission exists already.';
-
 export const permissionOptions = [
 export const permissionOptions = [
   { value: 1, label: 'View', description: 'Can view dashboards.' },
   { value: 1, label: 'View', description: 'Can view dashboards.' },
   { value: 2, label: 'Edit', description: 'Can add, edit and delete dashboards.' },
   { value: 2, label: 'Edit', description: 'Can add, edit and delete dashboards.' },
@@ -75,7 +73,6 @@ export const PermissionsStore = types
     isFolder: types.maybe(types.boolean),
     isFolder: types.maybe(types.boolean),
     dashboardId: types.maybe(types.number),
     dashboardId: types.maybe(types.number),
     items: types.optional(types.array(PermissionsStoreItem), []),
     items: types.optional(types.array(PermissionsStoreItem), []),
-    error: types.maybe(types.string),
     originalItems: types.optional(types.array(PermissionsStoreItem), []),
     originalItems: types.optional(types.array(PermissionsStoreItem), []),
     newType: types.optional(types.string, defaultNewType),
     newType: types.optional(types.string, defaultNewType),
     newItem: types.maybe(NewPermissionsItem),
     newItem: types.maybe(NewPermissionsItem),
@@ -88,7 +85,6 @@ export const PermissionsStore = types
         return isDuplicate(it, item);
         return isDuplicate(it, item);
       });
       });
       if (dupe) {
       if (dupe) {
-        self.error = duplicateError;
         return false;
         return false;
       }
       }
 
 
@@ -96,8 +92,7 @@ export const PermissionsStore = types
     },
     },
   }))
   }))
   .actions(self => {
   .actions(self => {
-    const resetNewType = () => {
-      self.error = null;
+    const resetNewTypeInternal = () => {
       self.newItem = NewPermissionsItem.create();
       self.newItem = NewPermissionsItem.create();
     };
     };
 
 
@@ -110,16 +105,14 @@ export const PermissionsStore = types
         self.dashboardId = dashboardId;
         self.dashboardId = dashboardId;
         self.items.clear();
         self.items.clear();
 
 
-        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
+        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/permissions`);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         self.items = items;
         self.items = items;
         self.originalItems = items;
         self.originalItems = items;
         self.fetching = false;
         self.fetching = false;
-        self.error = null;
       }),
       }),
 
 
       addStoreItem: flow(function* addStoreItem() {
       addStoreItem: flow(function* addStoreItem() {
-        self.error = null;
         let item = {
         let item = {
           type: self.newItem.type,
           type: self.newItem.type,
           permission: self.newItem.permission,
           permission: self.newItem.permission,
@@ -147,19 +140,21 @@ export const PermissionsStore = types
             throw Error('Unknown type: ' + self.newItem.type);
             throw Error('Unknown type: ' + self.newItem.type);
         }
         }
 
 
-        if (!self.isValid(item)) {
-          return undefined;
-        }
+        const updatedItems = self.items.peek();
+        const newItem = prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot);
+        updatedItems.push(newItem);
 
 
-        self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
-        resetNewType();
-        return updateItems(self);
+        try {
+          yield updateItems(self, updatedItems);
+          self.items.push(newItem);
+          resetNewTypeInternal();
+        } catch {}
+        yield Promise.resolve();
       }),
       }),
 
 
       removeStoreItem: flow(function* removeStoreItem(idx: number) {
       removeStoreItem: flow(function* removeStoreItem(idx: number) {
-        self.error = null;
         self.items.splice(idx, 1);
         self.items.splice(idx, 1);
-        return updateItems(self);
+        yield updateItems(self, self.items.peek());
       }),
       }),
 
 
       updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
       updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
@@ -167,9 +162,8 @@ export const PermissionsStore = types
         permission: number,
         permission: number,
         permissionName: string
         permissionName: string
       ) {
       ) {
-        self.error = null;
         self.items[idx].updatePermission(permission, permissionName);
         self.items[idx].updatePermission(permission, permissionName);
-        return updateItems(self);
+        yield updateItems(self, self.items.peek());
       }),
       }),
 
 
       setNewType(newType: string) {
       setNewType(newType: string) {
@@ -177,7 +171,7 @@ export const PermissionsStore = types
       },
       },
 
 
       resetNewType() {
       resetNewType() {
-        resetNewType();
+        resetNewTypeInternal();
       },
       },
 
 
       toggleAddPermissions() {
       toggleAddPermissions() {
@@ -190,12 +184,10 @@ export const PermissionsStore = types
     };
     };
   });
   });
 
 
-const updateItems = self => {
-  self.error = null;
-
+const updateItems = (self, items) => {
   const backendSrv = getEnv(self).backendSrv;
   const backendSrv = getEnv(self).backendSrv;
   const updated = [];
   const updated = [];
-  for (let item of self.items) {
+  for (let item of items) {
     if (item.inherited) {
     if (item.inherited) {
       continue;
       continue;
     }
     }
@@ -208,16 +200,9 @@ const updateItems = self => {
     });
     });
   }
   }
 
 
-  let res;
-  try {
-    res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/acl`, {
-      items: updated,
-    });
-  } catch (error) {
-    self.error = error;
-  }
-
-  return res;
+  return backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, {
+    items: updated,
+  });
 };
 };
 
 
 const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
 const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {

+ 93 - 129
public/sass/base/_fonts.scss

@@ -1,290 +1,254 @@
-@import "font_awesome";
-@import "grafana_icons";
+@import 'font_awesome';
+@import 'grafana_icons';
 
 
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto'), local('Roboto-Regular'), url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }

+ 1 - 1
public/sass/components/_dashboard_grid.scss

@@ -1,4 +1,4 @@
-@import '~react-grid-layout/css/styles.css';
+@import '~react-grid-layout-grafana/css/styles.css';
 @import '~react-resizable/css/styles.css';
 @import '~react-resizable/css/styles.css';
 
 
 .panel-in-fullscreen {
 .panel-in-fullscreen {

+ 1 - 1
public/sass/components/_scrollbar.scss

@@ -75,7 +75,7 @@
   border-radius: 6px;
   border-radius: 6px;
   width: 6px;
   width: 6px;
   /* there must be 'right' for ps__thumb-y */
   /* there must be 'right' for ps__thumb-y */
-  right: 2px;
+  right: 0px;
   /* please don't change 'position' */
   /* please don't change 'position' */
   position: absolute;
   position: absolute;
 }
 }

+ 3 - 0
public/sass/components/_view_states.scss

@@ -3,6 +3,9 @@
   .navbar {
   .navbar {
     display: none;
     display: none;
   }
   }
+  .scroll-canvas--dashboard {
+    height: 100%;
+  }
 }
 }
 
 
 .playlist-active,
 .playlist-active,

+ 1 - 0
public/test/mocks/common.ts

@@ -2,6 +2,7 @@ export const backendSrv = {
   get: jest.fn(),
   get: jest.fn(),
   getDashboard: jest.fn(),
   getDashboard: jest.fn(),
   getDashboardByUid: jest.fn(),
   getDashboardByUid: jest.fn(),
+  getFolderByUid: jest.fn(),
   post: jest.fn(),
   post: jest.fn(),
 };
 };
 
 

+ 78 - 0
tests/api/folder.test.ts

@@ -0,0 +1,78 @@
+import client from './client';
+import * as setup from './setup';
+
+describe('/api/folders', () => {
+  let state: any = {};
+
+  beforeAll(async () => {
+    state = await setup.ensureState({
+      orgName: 'api-test-org',
+      users: [
+        { user: setup.admin, role: 'Admin' },
+        { user: setup.editor, role: 'Editor' },
+        { user: setup.viewer, role: 'Viewer' },
+      ],
+      admin: setup.admin,
+      folders: [
+        {
+          title: 'Folder 1',
+          uid: 'f-01',
+        },
+        {
+          title: 'Folder 2',
+          uid: 'f-02',
+        },
+        {
+          title: 'Folder 3',
+          uid: 'f-03',
+        },
+      ],
+    });
+  });
+
+  describe('With admin user', () => {
+    it('can delete folder', async () => {
+      let rsp = await client.callAs(setup.admin).delete(`/api/folders/f-01`);
+      expect(rsp.data.title).toBe('Folder 1');
+    });
+
+    it('can update folder', async () => {
+      let rsp = await client.callAs(setup.admin).put(`/api/folders/f-02`, {
+        uid: 'f-02',
+        title: 'Folder 2 upd',
+        overwrite: true,
+      });
+      expect(rsp.data.title).toBe('Folder 2 upd');
+    });
+
+    it('can update folder uid', async () => {
+      let rsp = await client.callAs(setup.admin).put(`/api/folders/f-03`, {
+        uid: 'f-03-upd',
+        title: 'Folder 3 upd',
+        overwrite: true,
+      });
+      expect(rsp.data.uid).toBe('f-03-upd');
+      expect(rsp.data.title).toBe('Folder 3 upd');
+    });
+  });
+
+  describe('With viewer user', () => {
+    it('Cannot delete folder', async () => {
+      let rsp = await setup.expectError(() => {
+        return client.callAs(setup.viewer).delete(`/api/folders/f-02`);
+      });
+      expect(rsp.response.status).toBe(403);
+    });
+
+    it('Cannot update folder', async () => {
+      let rsp = await setup.expectError(() => {
+        return client.callAs(setup.viewer).put(`/api/folders/f-02`, {
+          uid: 'f-02',
+          title: 'Folder 2 upd',
+          overwrite: true,
+        });
+      });
+      expect(rsp.response.status).toBe(403);
+    });
+  });
+});

+ 17 - 1
tests/api/setup.ts

@@ -90,6 +90,18 @@ export async function createDashboard(user, dashboard) {
   return dashboard;
   return dashboard;
 }
 }
 
 
+export async function createFolder(user, folder) {
+  const rsp = await client.callAs(user).post(`/api/folders`, {
+    uid: folder.uid,
+    title: folder.title,
+    overwrite: true,
+  });
+  folder.id = rsp.id;
+  folder.url = rsp.url;
+
+  return folder;
+}
+
 export async function ensureState(state) {
 export async function ensureState(state) {
   const org = await getOrg(state.orgName);
   const org = await getOrg(state.orgName);
 
 
@@ -99,9 +111,13 @@ export async function ensureState(state) {
     await setUsingOrg(user, org);
     await setUsingOrg(user, org);
   }
   }
 
 
-  for (let dashboard of state.dashboards) {
+  for (let dashboard of state.dashboards || []) {
     await createDashboard(state.admin, dashboard);
     await createDashboard(state.admin, dashboard);
   }
   }
 
 
+  for (let folder of state.folders || []) {
+    await createFolder(state.admin, folder);
+  }
+
   return state;
   return state;
 }
 }

+ 13 - 6
yarn.lock

@@ -8275,16 +8275,23 @@ react-dom@^16.2.0:
     object-assign "^4.1.1"
     object-assign "^4.1.1"
     prop-types "^15.6.0"
     prop-types "^15.6.0"
 
 
-"react-draggable@^2.2.6 || ^3.0.3", react-draggable@^3.0.3:
+"react-draggable@^2.2.6 || ^3.0.3":
   version "3.0.3"
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.3.tgz#a6f9b3a7171981b76dadecf238316925cb9eacf4"
   resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.3.tgz#a6f9b3a7171981b76dadecf238316925cb9eacf4"
   dependencies:
   dependencies:
     classnames "^2.2.5"
     classnames "^2.2.5"
     prop-types "^15.5.10"
     prop-types "^15.5.10"
 
 
-react-grid-layout@^0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-0.16.2.tgz#ef09b0b6db4a9635799663658277ee2d26fa2994"
+react-draggable@^3.0.3:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d"
+  dependencies:
+    classnames "^2.2.5"
+    prop-types "^15.6.0"
+
+react-grid-layout-grafana@0.16.0:
+  version "0.16.0"
+  resolved "https://registry.yarnpkg.com/react-grid-layout-grafana/-/react-grid-layout-grafana-0.16.0.tgz#12242153fcd0bb80a26af8e41694bc2fde788b3a"
   dependencies:
   dependencies:
     classnames "2.x"
     classnames "2.x"
     lodash.isequal "^4.0.0"
     lodash.isequal "^4.0.0"
@@ -9773,9 +9780,9 @@ test-exclude@^4.1.1:
     read-pkg-up "^1.0.1"
     read-pkg-up "^1.0.1"
     require-main-filename "^1.0.1"
     require-main-filename "^1.0.1"
 
 
-"tether-drop@https://github.com/torkelo/drop":
+"tether-drop@https://github.com/torkelo/drop/tarball/master":
   version "1.5.0"
   version "1.5.0"
-  resolved "https://github.com/torkelo/drop#fc83ca88db0076fbf6359cbe1743a9ef0f1ee6e1"
+  resolved "https://github.com/torkelo/drop/tarball/master#6a3eb15b882b416f06e1e7ae04c7e57d08418020"
   dependencies:
   dependencies:
     tether "^1.1.0"
     tether "^1.1.0"