소스 검색

Merge branch 'develop' into 9879-login

Johannes Schill 8 년 전
부모
커밋
6a2b1e52b2
100개의 변경된 파일2250개의 추가작업 그리고 926개의 파일을 삭제
  1. 1 2
      .gitignore
  2. 14 1
      CHANGELOG.md
  3. 2 2
      README.md
  4. 1 1
      ROADMAP.md
  5. 2 7
      conf/defaults.ini
  6. 6 0
      conf/provisioning/dashboards/sample.yaml
  7. 8 8
      conf/provisioning/datasources/sample.yaml
  8. 2 7
      conf/sample.ini
  9. 18 1
      docs/sources/administration/provisioning.md
  10. 4 1
      docs/sources/features/datasources/cloudwatch.md
  11. 3 3
      docs/sources/features/datasources/postgres.md
  12. 3 5
      docs/sources/installation/configuration.md
  13. 1 1
      package.json
  14. 6 0
      packaging/deb/control/postinst
  15. 2 0
      packaging/deb/default/grafana-server
  16. 2 1
      packaging/deb/init.d/grafana-server
  17. 9 6
      packaging/deb/systemd/grafana-server.service
  18. 3 1
      packaging/mac/bin/grafana
  19. 6 0
      packaging/rpm/control/postinst
  20. 2 1
      packaging/rpm/init.d/grafana-server
  21. 2 0
      packaging/rpm/sysconfig/grafana-server
  22. 8 6
      packaging/rpm/systemd/grafana-server.service
  23. 10 10
      pkg/api/api.go
  24. 8 4
      pkg/api/avatar/avatar.go
  25. 20 21
      pkg/api/dashboard.go
  26. 1 1
      pkg/api/dashboard_acl.go
  27. 8 8
      pkg/api/dashboard_acl_test.go
  28. 20 4
      pkg/api/dashboard_test.go
  29. 4 4
      pkg/api/dtos/acl.go
  30. 1 1
      pkg/api/http_server.go
  31. 2 2
      pkg/api/index.go
  32. 1 0
      pkg/api/render.go
  33. 92 0
      pkg/api/team.go
  34. 44 0
      pkg/api/team_members.go
  35. 13 13
      pkg/api/team_test.go
  36. 0 92
      pkg/api/user_group.go
  37. 0 44
      pkg/api/user_group_members.go
  38. 25 10
      pkg/cmd/grafana-server/main.go
  39. 17 27
      pkg/cmd/grafana-server/server.go
  40. 5 0
      pkg/components/renderer/renderer.go
  41. 8 8
      pkg/models/dashboard_acl.go
  42. 10 6
      pkg/models/dashboards.go
  43. 0 6
      pkg/models/server.go
  44. 68 0
      pkg/models/team.go
  45. 55 0
      pkg/models/team_member.go
  46. 0 68
      pkg/models/user_group.go
  47. 0 55
      pkg/models/user_group_member.go
  48. 82 0
      pkg/services/dashboards/dashboards.go
  49. 10 10
      pkg/services/guardian/guardian.go
  50. 49 0
      pkg/services/provisioning/dashboards/config_reader.go
  51. 62 0
      pkg/services/provisioning/dashboards/config_reader_test.go
  52. 48 0
      pkg/services/provisioning/dashboards/dashboard.go
  53. 175 0
      pkg/services/provisioning/dashboards/file_reader.go
  54. 137 0
      pkg/services/provisioning/dashboards/file_reader_test.go
  55. 6 0
      pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml
  56. 12 0
      pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml
  57. 0 0
      pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/empty-json.json
  58. 6 0
      pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/invalid.json
  59. 173 0
      pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json
  60. 173 0
      pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json
  61. 173 0
      pkg/services/provisioning/dashboards/test-dashboards/one-dashboard/dashboard1.json
  62. 38 0
      pkg/services/provisioning/dashboards/types.go
  63. 10 4
      pkg/services/provisioning/datasources/datasources.go
  64. 48 0
      pkg/services/provisioning/datasources/test-configs/broken-yaml/commented.yaml
  65. 27 6
      pkg/services/provisioning/provisioning.go
  66. 1 0
      pkg/services/search/handlers_test.go
  67. 5 0
      pkg/services/sqlstore/dashboard.go
  68. 13 13
      pkg/services/sqlstore/dashboard_acl.go
  69. 11 11
      pkg/services/sqlstore/dashboard_acl_test.go
  70. 3 3
      pkg/services/sqlstore/migrations/dashboard_acl.go
  71. 1 1
      pkg/services/sqlstore/migrations/migrations.go
  72. 13 13
      pkg/services/sqlstore/migrations/team_mig.go
  73. 1 1
      pkg/services/sqlstore/org_users.go
  74. 1 1
      pkg/services/sqlstore/search_builder.go
  75. 2 0
      pkg/services/sqlstore/sqlstore.go
  76. 233 0
      pkg/services/sqlstore/team.go
  77. 114 0
      pkg/services/sqlstore/team_test.go
  78. 1 1
      pkg/services/sqlstore/user.go
  79. 0 233
      pkg/services/sqlstore/user_group.go
  80. 0 114
      pkg/services/sqlstore/user_group_test.go
  81. 8 9
      pkg/setting/setting.go
  82. 10 8
      pkg/social/github_oauth.go
  83. 9 3
      pkg/tsdb/cloudwatch/credentials.go
  84. 1 1
      pkg/tsdb/postgres/macros.go
  85. 1 1
      pkg/tsdb/postgres/macros_test.go
  86. 2 2
      public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
  87. 1 1
      public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap
  88. 0 2
      public/app/core/components/dashboard_selector.ts
  89. 3 3
      public/app/core/components/manage_dashboards/manage_dashboards.html
  90. 9 1
      public/app/core/components/search/search.html
  91. 5 3
      public/app/core/components/search/search_results.html
  92. 10 10
      public/app/core/components/team_picker.ts
  93. 2 2
      public/app/core/core.ts
  94. 12 13
      public/app/core/directives/dash_class.js
  95. 0 1
      public/app/core/directives/dash_edit_link.js
  96. 3 2
      public/app/core/routes/dashboard_loaders.ts
  97. 11 6
      public/app/core/routes/routes.ts
  98. 10 8
      public/app/core/services/keybindingSrv.ts
  99. 7 0
      public/app/core/services/popover_srv.ts
  100. 1 1
      public/app/features/annotations/annotation_tooltip.ts

+ 1 - 2
.gitignore

@@ -39,8 +39,7 @@ conf/custom.ini
 fig.yml
 docker-compose.yml
 docker-compose.yaml
-/conf/dashboards/custom.yaml
-/conf/datasources/custom.yaml
+/conf/provisioning/**/custom.yaml
 profile.cov
 /grafana
 .notouch

+ 14 - 1
CHANGELOG.md

@@ -2,13 +2,20 @@
 
 ### WIP (in develop branch currently as its unstable or unfinished)
 - Dashboard folders
-- User groups
+- Teams
 - Dashboard permissions (on folder & dashboard level), permissions can be assigned to groups or individual users
 - UX changes to nav & side menu
 - New dashboard grid layout system
 
 # 4.7.0 (unreleased)
 
+## Breaking changes
+
+`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). 
+
+Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. 
+From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. 
+
 ## New Features
 * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson)
 * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm)
@@ -17,6 +24,7 @@
 * **Datasources**: Its now possible to configure datasources with config files [#1789](https://github.com/grafana/grafana/issues/1789)
 * **Graphite**: Query editor updated to support new query by tag features [#9230](https://github.com/grafana/grafana/issues/9230)
 * **Dashboard history**: New config file option versions_to_keep sets how many versions per dashboard to store, [#9671](https://github.com/grafana/grafana/issues/9671)
+* **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269)
 
 ## Minor
 * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend)
@@ -25,9 +33,14 @@
 * **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871)
 * **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798)
 * **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler)
+* **Github**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx 
+[@adiletmaratov](https://github.com/adiletmaratov)
+* **Backend**: Fixed bug where Grafana exited before all sub routines where finished [#10131](https://github.com/grafana/grafana/issues/10131)
+
 ## Tech
 * **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
 
+
 ## Fixes
 * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand)
 * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu)

+ 2 - 2
README.md

@@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra
 Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
 
 ## Run from master
-If you want to build a package yourself, or contribute. Here is a guide for how to do that. You can always find
+If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find
 the latest master builds [here](https://grafana.com/grafana/download)
 
 ### Dependencies
@@ -97,7 +97,7 @@ Writing & watching frontend tests (we have two test runners)
 
 ## Contribute
 
-If you have any idea for an improvement or found a bug do not hesitate to open an issue.
+If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
 And if you have time clone this repo and submit a pull request and help me make Grafana
 the kickass metrics & devops dashboard we all dream about!
 

+ 1 - 1
ROADMAP.md

@@ -6,7 +6,7 @@ But it will give you an idea of our current vision and plan.
 ### Short term (1-4 months)
 
  - Release Grafana v5
-  - User groups
+  - Teams
   - Dashboard folders
   - Dashboard & folder permissions (assigned to users or groups)
   - New Dashboard layout engine

+ 2 - 7
conf/defaults.ini

@@ -20,8 +20,8 @@ logs = data/log
 # Directory where grafana will automatically scan and look for plugins
 plugins = data/plugins
 
-# Config files containing datasources that will be configured at startup
-datasources = conf/datasources
+# folder that contains provisioning config files that grafana will apply on startup and while running.
+provisioning = conf/provisioning
 
 #################################### Server ##############################
 [server]
@@ -391,11 +391,6 @@ facility =
 # Syslog tag. By default, the process' argv[0] is used.
 tag =
 
-#################################### Dashboard JSON files ################
-[dashboards.json]
-enabled = false
-path = /var/lib/grafana/dashboards
-
 #################################### Usage Quotas ########################
 [quota]
 enabled = false

+ 6 - 0
conf/provisioning/dashboards/sample.yaml

@@ -0,0 +1,6 @@
+# - name: 'default'
+#   org_id: 1
+#   folder: ''
+#   type: file
+#   options:
+#     folder: /var/lib/grafana/dashboards

+ 8 - 8
conf/datasources/datasources.yaml → conf/provisioning/datasources/sample.yaml

@@ -1,11 +1,11 @@
-# list of datasources that should be deleted from the database
-delete_datasources:
-  # - name: Graphite
-  #   org_id: 1
+# # list of datasources that should be deleted from the database
+#delete_datasources:
+#   - name: Graphite
+#     org_id: 1
 
-# list of datasources to insert/update depending 
-# whats available in the datbase
-datasources:
+# # list of datasources to insert/update depending
+# # whats available in the datbase
+#datasources:
 #   # <string, required> name of the datasource. Required
 # - name: Graphite
 #   # <string, required> datasource type. Required
@@ -33,7 +33,7 @@ datasources:
 #   # <bool> mark as default datasource. Max one per org
 #   is_default:
 #   # <map> fields that will be converted to json and stored in json_data
-#   json_data: 
+#   json_data:
 #      graphiteVersion: "1.1"
 #      tlsAuth: true
 #      tlsAuthWithCACert: true

+ 2 - 7
conf/sample.ini

@@ -20,8 +20,8 @@
 # Directory where grafana will automatically scan and look for plugins
 ;plugins = /var/lib/grafana/plugins
 
-# Config files containing datasources that will be configured at startup
-;datasources = conf/datasources
+# folder that contains provisioning config files that grafana will apply on startup and while running.
+; provisioning = conf/provisioning
 
 #################################### Server ####################################
 [server]
@@ -367,11 +367,6 @@ log_queries =
 ;tag =
 
 
-;#################################### Dashboard JSON files ##########################
-[dashboards.json]
-;enabled = false
-;path = /var/lib/grafana/dashboards
-
 #################################### Alerting ############################
 [alerting]
 # Disable alerting engine & UI features

+ 18 - 1
docs/sources/administration/provisioning.md

@@ -74,7 +74,7 @@ Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://gith
 
 > This feature is available from v5.0
 
-It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`conf/datasources`](/installation/configuration/#datasources) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.
+It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`provisioning/datasources`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.
 
 ### Running multiple grafana instances.
 If you are running multiple instances of Grafana you might run into problems if they have different versions of the datasource.yaml configuration file. The best way to solve this problem is to add a version number to each datasource in the configuration and increase it when you update the config. Grafana will only update datasources with the same or lower version number than specified in the config. That way old configs cannot overwrite newer configs if they restart at the same time. 
@@ -165,3 +165,20 @@ Secure json data is a map of settings that will be encrypted with [secret key](/
 | tlsClientKey | string | *All* |TLS Client key for outgoing requests |
 | password | string | Postgre | password | 
 | user | string | Postgre | user | 
+
+### Dashboards
+
+It's possible to manage dashboards in Grafana by adding one or more yaml config files in the [`provisioning/dashboards`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `dashboards providers` that will load dashboards into grafana. Currently we only support reading dashboards from file but we will add more providers in the future. 
+
+The dashboard provider config file looks like this
+
+```yaml
+- name: 'default'
+  org_id: 1
+  folder: ''
+  type: file
+  options:
+    folder: /var/lib/grafana/dashboards
+```
+
+When grafana starts it will update/insert all dashboards available in the configured folders. If you modify the file the dashboard will also be updated. 

+ 4 - 1
docs/sources/features/datasources/cloudwatch.md

@@ -78,11 +78,14 @@ CloudWatch Datasource Plugin provides the following queries you can specify in t
 edit view. They allow you to fill a variable's options list with things like `region`, `namespaces`, `metric names`
 and `dimension keys/values`.
 
+In place of `region` you can specify `default` to use the default region configured in the datasource for the query,
+e.g. `metrics(AWS/DynamoDB, default)` or `dimension_values(default, ..., ..., ...)`.
+
 Name | Description
 ------- | --------
 *regions()* | Returns a list of regions AWS provides their service.
 *namespaces()* | Returns a list of namespaces CloudWatch support.
-*metrics(namespace, [region])* | Returns a list of metrics in the namespace. (specify region for custom metrics)
+*metrics(namespace, [region])* | Returns a list of metrics in the namespace. (specify region or use "default" for custom metrics)
 *dimension_keys(namespace)* | Returns a list of dimension keys in the namespace.
 *dimension_values(region, namespace, metric, dimension_key)* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric` and `dimension_key`.
 *ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`.

+ 3 - 3
docs/sources/features/datasources/postgres.md

@@ -48,7 +48,7 @@ Macro example | Description
 *$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *extract(epoch from dateColumn) BETWEEN 1494410783 AND 1494497183*
 *$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *to_timestamp(1494410783)*
 *$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *to_timestamp(1494497183)*
-*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from "dateColumn")/300)::bigint*300*
+*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from dateColumn)/300)::bigint*300 AS time*
 *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
 *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
 *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
@@ -94,7 +94,7 @@ Example with `metric` column
 
 ```sql
 SELECT
-  $__timeGroup(time_date_time,'5m') as time,
+  $__timeGroup(time_date_time,'5m'),
   min(value_double),
   'min' as metric
 FROM test_data
@@ -107,7 +107,7 @@ Example with multiple columns:
 
 ```sql
 SELECT
-  $__timeGroup(time_date_time,'5m') as time,
+  $__timeGroup(time_date_time,'5m'),
   min(value_double) as min_value,
   max(value_double) as max_value
 FROM test_data

+ 3 - 5
docs/sources/installation/configuration.md

@@ -91,12 +91,11 @@ file.
 
 Directory where grafana will automatically scan and look for plugins
 
-### datasources
+### provisioning
 
 > This feature is available in 5.0+
 
-Config files containing datasources that will be configured at startup. 
-You can read more about the config files at the [provisioning page](/administration/provisioning/#datasources).
+Folder that contains [provisioning](/administration/provisioning) config files that grafana will apply on startup. Dashboards will be reloaded when the json files changes
 
 ## [server]
 
@@ -635,8 +634,7 @@ Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1.
 
 ## [dashboards.json]
 
-If you have a system that automatically builds dashboards as json files you can enable this feature to have the
-Grafana backend index those json dashboards which will make them appear in regular dashboard search.
+> This have been replaced with dashboards [provisioning](/administration/provisioning) in 5.0+
 
 ### enabled
 `true` or `false`. Is disabled by default.

+ 1 - 1
package.json

@@ -129,7 +129,7 @@
     "prop-types": "^15.6.0",
     "react": "^16.1.1",
     "react-dom": "^16.1.1",
-    "react-grid-layout": "^0.16.0",
+    "react-grid-layout": "^0.16.1",
     "react-sizeme": "^2.3.6",
     "remarkable": "^1.7.1",
     "rxjs": "^5.4.3",

+ 6 - 0
packaging/deb/control/postinst

@@ -31,6 +31,12 @@ case "$1" in
     cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml
   fi
 
+  if [ ! -f $PROVISIONING_CFG_DIR ]; then
+    mkdir -p $PROVISIONING_CFG_DIR/dashboards $PROVISIONING_CFG_DIR/datasources
+    cp /usr/share/grafana/conf/provisioning/dashboards/sample.yaml $PROVISIONING_CFG_DIR/dashboards/sample.yaml
+    cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml
+  fi 
+
 	# configuration files should not be modifiable by grafana user, as this can be a security issue
 	chown -Rh root:$GRAFANA_GROUP /etc/grafana/*
 	chmod 755 /etc/grafana

+ 2 - 0
packaging/deb/default/grafana-server

@@ -18,5 +18,7 @@ RESTART_ON_UPGRADE=true
 
 PLUGINS_DIR=/var/lib/grafana/plugins
 
+PROVISIONING_CFG_DIR=/etc/grafana/provisioning
+
 # Only used on systemd systems
 PID_FILE_DIR=/var/run/grafana

+ 2 - 1
packaging/deb/init.d/grafana-server

@@ -33,6 +33,7 @@ DATA_DIR=/var/lib/grafana
 PLUGINS_DIR=/var/lib/grafana/plugins
 LOG_DIR=/var/log/grafana
 CONF_FILE=$CONF_DIR/grafana.ini
+PROVISIONING_CFG_DIR=$CONF_DIR/provisioning
 MAX_OPEN_FILES=10000
 PID_FILE=/var/run/$NAME.pid
 DAEMON=/usr/sbin/$NAME
@@ -55,7 +56,7 @@ if [ -f "$DEFAULT" ]; then
 	. "$DEFAULT"
 fi
 
-DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}"
+DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}"
 
 function checkUser() {
   if [ `id -u` -ne 0 ]; then

+ 9 - 6
packaging/deb/systemd/grafana-server.service

@@ -14,12 +14,15 @@ Restart=on-failure
 WorkingDirectory=/usr/share/grafana
 RuntimeDirectory=grafana
 RuntimeDirectoryMode=0750
-ExecStart=/usr/sbin/grafana-server                                        \
-                            --config=${CONF_FILE}                         \
-                            --pidfile=${PID_FILE_DIR}/grafana-server.pid  \
-                            cfg:default.paths.logs=${LOG_DIR}             \
-                            cfg:default.paths.data=${DATA_DIR}            \
-                            cfg:default.paths.plugins=${PLUGINS_DIR}
+ExecStart=/usr/sbin/grafana-server                                                  \
+                            --config=${CONF_FILE}                                   \
+                            --pidfile=${PID_FILE_DIR}/grafana-server.pid            \
+                            cfg:default.paths.logs=${LOG_DIR}                       \
+                            cfg:default.paths.data=${DATA_DIR}                      \
+                            cfg:default.paths.plugins=${PLUGINS_DIR}                \
+                            cfg:default.paths.provisioning=${PROVISIONING_CFG_DIR}  
+
+
 LimitNOFILE=10000
 TimeoutStopSec=20
 UMask=0027

+ 3 - 1
packaging/mac/bin/grafana

@@ -6,10 +6,12 @@ HOMEPATH=/usr/local/share/grafana
 LOGPATH=/usr/local/var/log/grafana
 DATAPATH=/usr/local/var/lib/grafana
 PLUGINPATH=/usr/local/var/lib/grafana/plugins
+DATASOURCECFGPATH=/usr/local/etc/grafana/datasources
+DASHBOARDSCFGPATH=/usr/local/etc/grafana/dashboards
 
 case "$1" in
 start)
-  $EXECUTABLE --config=$CONFIG --homepath=$HOMEPATH cfg:default.paths.logs=$LOGPATH cfg:default.paths.data=$DATAPATH cfg:default.paths.plugins=$PLUGINPATH 2> /dev/null &
+  $EXECUTABLE --config=$CONFIG --homepath=$HOMEPATH cfg:default.paths.datasources=$DATASOURCECFGPATH cfg:default.paths.dashboards=$DASHBOARDSCFGPATH cfg:default.paths.logs=$LOGPATH cfg:default.paths.data=$DATAPATH cfg:default.paths.plugins=$PLUGINPATH 2> /dev/null &
   [ $? -eq 0 ] && echo "$DAEMON started"
 ;;
 stop)

+ 6 - 0
packaging/rpm/control/postinst

@@ -45,6 +45,12 @@ if [ $1 -eq 1 ] ; then
     cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml
   fi
 
+  if [ ! -f $PROVISIONING_CFG_DIR ]; then
+    mkdir -p $PROVISIONING_CFG_DIR/dashboards $PROVISIONING_CFG_DIR/datasources
+    cp /usr/share/grafana/conf/provisioning/dashboards/sample.yaml $PROVISIONING_CFG_DIR/dashboards/sample.yaml
+    cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml
+  fi 
+
  	# Set user permissions on /var/log/grafana, /var/lib/grafana
 	mkdir -p /var/log/grafana /var/lib/grafana
 	chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana

+ 2 - 1
packaging/rpm/init.d/grafana-server

@@ -32,6 +32,7 @@ DATA_DIR=/var/lib/grafana
 PLUGINS_DIR=/var/lib/grafana/plugins
 LOG_DIR=/var/log/grafana
 CONF_FILE=$CONF_DIR/grafana.ini
+PROVISIONING_CFG_DIR=$CONF_DIR/provisioning
 MAX_OPEN_FILES=10000
 PID_FILE=/var/run/$NAME.pid
 DAEMON=/usr/sbin/$NAME
@@ -59,7 +60,7 @@ fi
 # overwrite settings from default file
 [ -e /etc/sysconfig/$NAME ] && . /etc/sysconfig/$NAME
 
-DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}"
+DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}"
 
 function isRunning() {
   status -p $PID_FILE $NAME > /dev/null 2>&1

+ 2 - 0
packaging/rpm/sysconfig/grafana-server

@@ -18,5 +18,7 @@ RESTART_ON_UPGRADE=true
 
 PLUGINS_DIR=/var/lib/grafana/plugins
 
+PROVISIONING_CFG_DIR=/etc/grafana/provisioning
+
 # Only used on systemd systems
 PID_FILE_DIR=/var/run/grafana

+ 8 - 6
packaging/rpm/systemd/grafana-server.service

@@ -14,12 +14,14 @@ Restart=on-failure
 WorkingDirectory=/usr/share/grafana
 RuntimeDirectory=grafana
 RuntimeDirectoryMode=0750
-ExecStart=/usr/sbin/grafana-server                                        \
-                            --config=${CONF_FILE}                         \
-                            --pidfile=${PID_FILE_DIR}/grafana-server.pid  \
-                            cfg:default.paths.logs=${LOG_DIR}             \
-                            cfg:default.paths.data=${DATA_DIR}            \
-                            cfg:default.paths.plugins=${PLUGINS_DIR}
+ExecStart=/usr/sbin/grafana-server                                                  \
+                            --config=${CONF_FILE}                                   \
+                            --pidfile=${PID_FILE_DIR}/grafana-server.pid            \
+                            cfg:default.paths.logs=${LOG_DIR}                       \
+                            cfg:default.paths.data=${DATA_DIR}                      \
+                            cfg:default.paths.plugins=${PLUGINS_DIR}                \
+                            cfg:default.paths.provisioning=${PROVISIONING_CFG_DIR}  
+
 LimitNOFILE=10000
 TimeoutStopSec=20
 

+ 10 - 10
pkg/api/api.go

@@ -135,16 +135,16 @@ func (hs *HttpServer) registerRoutes() {
 			usersRoute.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg))
 		}, reqGrafanaAdmin)
 
-		// user group (admin permission required)
-		apiRoute.Group("/user-groups", func(userGroupsRoute RouteRegister) {
-			userGroupsRoute.Get("/:userGroupId", wrap(GetUserGroupById))
-			userGroupsRoute.Get("/search", wrap(SearchUserGroups))
-			userGroupsRoute.Post("/", quota("user-groups"), bind(m.CreateUserGroupCommand{}), wrap(CreateUserGroup))
-			userGroupsRoute.Put("/:userGroupId", bind(m.UpdateUserGroupCommand{}), wrap(UpdateUserGroup))
-			userGroupsRoute.Delete("/:userGroupId", wrap(DeleteUserGroupById))
-			userGroupsRoute.Get("/:userGroupId/members", wrap(GetUserGroupMembers))
-			userGroupsRoute.Post("/:userGroupId/members", quota("user-groups"), bind(m.AddUserGroupMemberCommand{}), wrap(AddUserGroupMember))
-			userGroupsRoute.Delete("/:userGroupId/members/:userId", wrap(RemoveUserGroupMember))
+		// team (admin permission required)
+		apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
+			teamsRoute.Get("/:teamId", wrap(GetTeamById))
+			teamsRoute.Get("/search", wrap(SearchTeams))
+			teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
+			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
+			teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
+			teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
+			teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
 		}, reqOrgAdmin)
 
 		// org information available to all users.

+ 8 - 4
pkg/api/avatar/avatar.go

@@ -25,6 +25,8 @@ import (
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/setting"
 	"gopkg.in/macaron.v1"
+
+	gocache "github.com/patrickmn/go-cache"
 )
 
 var gravatarSource string
@@ -92,7 +94,7 @@ func (this *Avatar) Update() (err error) {
 
 type CacheServer struct {
 	notFound *Avatar
-	cache    map[string]*Avatar
+	cache    *gocache.Cache
 }
 
 func (this *CacheServer) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) {
@@ -110,7 +112,9 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
 
 	var avatar *Avatar
 
-	if avatar, _ = this.cache[hash]; avatar == nil {
+	if obj, exist := this.cache.Get(hash); exist {
+		avatar = obj.(*Avatar)
+	} else {
 		avatar = New(hash)
 	}
 
@@ -124,7 +128,7 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
 	if avatar.notFound {
 		avatar = this.notFound
 	} else {
-		this.cache[hash] = avatar
+		this.cache.Add(hash, avatar, gocache.DefaultExpiration)
 	}
 
 	ctx.Resp.Header().Add("Content-Type", "image/jpeg")
@@ -146,7 +150,7 @@ func NewCacheServer() *CacheServer {
 
 	return &CacheServer{
 		notFound: newNotFound(),
-		cache:    make(map[string]*Avatar),
+		cache:    gocache.New(time.Hour, time.Hour*2),
 	}
 }
 

+ 20 - 21
pkg/api/dashboard.go

@@ -6,6 +6,8 @@ import (
 	"os"
 	"path"
 
+	"github.com/grafana/grafana/pkg/services/dashboards"
+
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/dashdiffs"
@@ -15,7 +17,6 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
-	"github.com/grafana/grafana/pkg/services/alerting"
 	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
@@ -157,13 +158,6 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 
 	dash := cmd.GetDashboardModel()
 
-	// look up existing dashboard
-	if dash.Id > 0 {
-		if existing, _ := getDashboardHelper(c.OrgId, "", dash.Id); existing != nil {
-			dash.HasAcl = existing.HasAcl
-		}
-	}
-
 	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
 	if canSave, err := guardian.CanSave(); err != nil || !canSave {
 		return dashboardGuardianResponse(err)
@@ -188,17 +182,24 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		}
 	}
 
-	validateAlertsCmd := alerting.ValidateDashboardAlertsCommand{
+	dashItem := &dashboards.SaveDashboardItem{
+		Dashboard: dash,
+		Message:   cmd.Message,
 		OrgId:     c.OrgId,
 		UserId:    c.UserId,
-		Dashboard: dash,
+		Overwrite: cmd.Overwrite,
 	}
 
-	if err := bus.Dispatch(&validateAlertsCmd); err != nil {
+	dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem)
+
+	if err == m.ErrDashboardTitleEmpty {
+		return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
+	}
+
+	if err == m.ErrDashboardContainsInvalidAlertData {
 		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 	}
 
-	err := bus.Dispatch(&cmd)
 	if err != nil {
 		if err == m.ErrDashboardWithSameNameExists {
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
@@ -220,18 +221,16 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		return ApiError(500, "Failed to save dashboard", err)
 	}
 
-	alertCmd := alerting.UpdateDashboardAlertsCommand{
-		OrgId:     c.OrgId,
-		UserId:    c.UserId,
-		Dashboard: cmd.Result,
-	}
-
-	if err := bus.Dispatch(&alertCmd); err != nil {
-		return ApiError(500, "Failed to save alerts", err)
+	if err == m.ErrDashboardFailedToUpdateAlertData {
+		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 	}
 
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
-	return Json(200, util.DynMap{"status": "success", "slug": cmd.Result.Slug, "version": cmd.Result.Version, "id": cmd.Result.Id})
+	return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id})
+}
+
+func canEditDashboard(role m.RoleType) bool {
+	return role == m.ROLE_ADMIN || role == m.ROLE_EDITOR || role == m.ROLE_READ_ONLY_EDITOR
 }
 
 func GetHomeDashboard(c *middleware.Context) Response {

+ 1 - 1
pkg/api/dashboard_acl.go

@@ -43,7 +43,7 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
 			OrgId:       c.OrgId,
 			DashboardId: dashId,
 			UserId:      item.UserId,
-			UserGroupId: item.UserGroupId,
+			TeamId:      item.TeamId,
 			Role:        item.Role,
 			Permission:  item.Permission,
 			Created:     time.Now(),

+ 8 - 8
pkg/api/dashboard_acl_test.go

@@ -16,8 +16,8 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			{Id: 1, OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
 			{Id: 2, OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
 			{Id: 3, OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
-			{Id: 4, OrgId: 1, DashboardId: 1, UserGroupId: 1, Permission: m.PERMISSION_VIEW},
-			{Id: 5, OrgId: 1, DashboardId: 1, UserGroupId: 2, Permission: m.PERMISSION_ADMIN},
+			{Id: 4, OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
+			{Id: 5, OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
 		}
 		dtoRes := transformDashboardAclsToDTOs(mockResult)
 
@@ -31,9 +31,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			return nil
 		})
 
-		userGroupResp := []*m.UserGroup{}
-		bus.AddHandler("test", func(query *m.GetUserGroupsByUserQuery) error {
-			query.Result = userGroupResp
+		teamResp := []*m.Team{}
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = teamResp
 			return nil
 		})
 
@@ -81,9 +81,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 				})
 			})
 
-			Convey("When user is a member of a user group in the ACL with admin permission", func() {
+			Convey("When user is a member of a team in the ACL with admin permission", func() {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
-					userGroupResp = append(userGroupResp, &m.UserGroup{Id: 2, OrgId: 1, Name: "UG2"})
+					teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"})
 
 					bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
 						return nil
@@ -165,7 +165,7 @@ func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardA
 			DashboardId: acl.DashboardId,
 			Permission:  acl.Permission,
 			UserId:      acl.UserId,
-			UserGroupId: acl.UserGroupId,
+			TeamId:      acl.TeamId,
 		}
 		dtos = append(dtos, dto)
 	}

+ 20 - 4
pkg/api/dashboard_test.go

@@ -14,10 +14,23 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/alerting"
+	"github.com/grafana/grafana/pkg/services/dashboards"
 
 	. "github.com/smartystreets/goconvey/convey"
 )
 
+type fakeDashboardRepo struct {
+	inserted     []*dashboards.SaveDashboardItem
+	getDashboard []*m.Dashboard
+}
+
+func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*m.Dashboard, error) {
+	repo.inserted = append(repo.inserted, json)
+	return json.Dashboard, nil
+}
+
+var fakeRepo *fakeDashboardRepo
+
 func TestDashboardApiEndpoint(t *testing.T) {
 	Convey("Given a dashboard with a parent folder which does not have an acl", t, func() {
 		fakeDash := m.NewDashboard("Child dash")
@@ -43,8 +56,8 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 		})
 
-		bus.AddHandler("test", func(query *m.GetUserGroupsByUserQuery) error {
-			query.Result = []*m.UserGroup{}
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = []*m.Team{}
 			return nil
 		})
 
@@ -204,8 +217,8 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 		})
 
-		bus.AddHandler("test", func(query *m.GetUserGroupsByUserQuery) error {
-			query.Result = []*m.UserGroup{}
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = []*m.Team{}
 			return nil
 		})
 
@@ -500,6 +513,9 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
 			return PostDashboard(c, cmd)
 		})
 
+		fakeRepo = &fakeDashboardRepo{}
+		dashboards.SetRepository(fakeRepo)
+
 		sc.m.Post(routePattern, sc.defaultHandler)
 
 		fn(sc)

+ 4 - 4
pkg/api/dtos/acl.go

@@ -9,8 +9,8 @@ type UpdateDashboardAclCommand struct {
 }
 
 type DashboardAclUpdateItem struct {
-	UserId      int64            `json:"userId"`
-	UserGroupId int64            `json:"userGroupId"`
-	Role        *m.RoleType      `json:"role,omitempty"`
-	Permission  m.PermissionType `json:"permission"`
+	UserId     int64            `json:"userId"`
+	TeamId     int64            `json:"teamId"`
+	Role       *m.RoleType      `json:"role,omitempty"`
+	Permission m.PermissionType `json:"permission"`
 }

+ 1 - 1
pkg/api/http_server.go

@@ -95,7 +95,7 @@ func (hs *HttpServer) Start(ctx context.Context) error {
 
 func (hs *HttpServer) Shutdown(ctx context.Context) error {
 	err := hs.httpSrv.Shutdown(ctx)
-	hs.log.Info("stopped http server")
+	hs.log.Info("Stopped HTTP server")
 	return err
 }
 

+ 2 - 2
pkg/api/index.go

@@ -231,8 +231,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 					Text:        "Teams",
 					Id:          "teams",
 					Description: "Manage org groups",
-					Icon:        "gicon gicon-user-group",
-					Url:         setting.AppSubUrl + "/org/user-groups",
+					Icon:        "gicon gicon-team",
+					Url:         setting.AppSubUrl + "/org/teams",
 				},
 				{
 					Text:        "Plugins",

+ 1 - 0
pkg/api/render.go

@@ -26,6 +26,7 @@ func RenderToPng(c *middleware.Context) {
 		UserId:   c.UserId,
 		OrgRole:  c.OrgRole,
 		Timezone: queryReader.Get("tz", ""),
+		Encoding: queryReader.Get("encoding", ""),
 	}
 
 	pngPath, err := renderer.RenderToPng(renderOpts)

+ 92 - 0
pkg/api/team.go

@@ -0,0 +1,92 @@
+package api
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+// POST /api/teams
+func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response {
+	cmd.OrgId = c.OrgId
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamNameTaken {
+			return ApiError(409, "Team name taken", err)
+		}
+		return ApiError(500, "Failed to create Team", err)
+	}
+
+	return Json(200, &util.DynMap{
+		"teamId":  cmd.Result.Id,
+		"message": "Team created",
+	})
+}
+
+// PUT /api/teams/:teamId
+func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
+	cmd.Id = c.ParamsInt64(":teamId")
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamNameTaken {
+			return ApiError(400, "Team name taken", err)
+		}
+		return ApiError(500, "Failed to update Team", err)
+	}
+
+	return ApiSuccess("Team updated")
+}
+
+// DELETE /api/teams/:teamId
+func DeleteTeamById(c *middleware.Context) Response {
+	if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil {
+		if err == m.ErrTeamNotFound {
+			return ApiError(404, "Failed to delete Team. ID not found", nil)
+		}
+		return ApiError(500, "Failed to update Team", err)
+	}
+	return ApiSuccess("Team deleted")
+}
+
+// GET /api/teams/search
+func SearchTeams(c *middleware.Context) Response {
+	perPage := c.QueryInt("perpage")
+	if perPage <= 0 {
+		perPage = 1000
+	}
+	page := c.QueryInt("page")
+	if page < 1 {
+		page = 1
+	}
+
+	query := m.SearchTeamsQuery{
+		Query: c.Query("query"),
+		Name:  c.Query("name"),
+		Page:  page,
+		Limit: perPage,
+		OrgId: c.OrgId,
+	}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to search Teams", err)
+	}
+
+	query.Result.Page = page
+	query.Result.PerPage = perPage
+
+	return Json(200, query.Result)
+}
+
+// GET /api/teams/:teamId
+func GetTeamById(c *middleware.Context) Response {
+	query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		if err == m.ErrTeamNotFound {
+			return ApiError(404, "Team not found", err)
+		}
+
+		return ApiError(500, "Failed to get Team", err)
+	}
+
+	return Json(200, &query.Result)
+}

+ 44 - 0
pkg/api/team_members.go

@@ -0,0 +1,44 @@
+package api
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+// GET /api/teams/:teamId/members
+func GetTeamMembers(c *middleware.Context) Response {
+	query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to get Team Members", err)
+	}
+
+	return Json(200, query.Result)
+}
+
+// POST /api/teams/:teamId/members
+func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response {
+	cmd.TeamId = c.ParamsInt64(":teamId")
+	cmd.OrgId = c.OrgId
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamMemberAlreadyAdded {
+			return ApiError(400, "User is already added to this team", err)
+		}
+		return ApiError(500, "Failed to add Member to Team", err)
+	}
+
+	return Json(200, &util.DynMap{
+		"message": "Member added to Team",
+	})
+}
+
+// DELETE /api/teams/:teamId/members/:userId
+func RemoveTeamMember(c *middleware.Context) Response {
+	if err := bus.Dispatch(&m.RemoveTeamMemberCommand{TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
+		return ApiError(500, "Failed to remove Member from Team", err)
+	}
+	return ApiSuccess("Team Member removed")
+}

+ 13 - 13
pkg/api/user_group_test.go → pkg/api/team_test.go

@@ -10,21 +10,21 @@ import (
 	. "github.com/smartystreets/goconvey/convey"
 )
 
-func TestUserGroupApiEndpoint(t *testing.T) {
-	Convey("Given two user groups", t, func() {
-		mockResult := models.SearchUserGroupQueryResult{
-			UserGroups: []*models.UserGroup{
-				{Name: "userGroup1"},
-				{Name: "userGroup2"},
+func TestTeamApiEndpoint(t *testing.T) {
+	Convey("Given two teams", t, func() {
+		mockResult := models.SearchTeamQueryResult{
+			Teams: []*models.Team{
+				{Name: "team1"},
+				{Name: "team2"},
 			},
 			TotalCount: 2,
 		}
 
 		Convey("When searching with no parameters", func() {
-			loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) {
+			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
 				var sentLimit int
 				var sendPage int
-				bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error {
+				bus.AddHandler("test", func(query *models.SearchTeamsQuery) error {
 					query.Result = mockResult
 
 					sentLimit = query.Limit
@@ -33,7 +33,7 @@ func TestUserGroupApiEndpoint(t *testing.T) {
 					return nil
 				})
 
-				sc.handlerFunc = SearchUserGroups
+				sc.handlerFunc = SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 				So(sentLimit, ShouldEqual, 1000)
@@ -43,15 +43,15 @@ func TestUserGroupApiEndpoint(t *testing.T) {
 				So(err, ShouldBeNil)
 
 				So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2)
-				So(len(respJSON.Get("userGroups").MustArray()), ShouldEqual, 2)
+				So(len(respJSON.Get("teams").MustArray()), ShouldEqual, 2)
 			})
 		})
 
 		Convey("When searching with page and perpage parameters", func() {
-			loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) {
+			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
 				var sentLimit int
 				var sendPage int
-				bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error {
+				bus.AddHandler("test", func(query *models.SearchTeamsQuery) error {
 					query.Result = mockResult
 
 					sentLimit = query.Limit
@@ -60,7 +60,7 @@ func TestUserGroupApiEndpoint(t *testing.T) {
 					return nil
 				})
 
-				sc.handlerFunc = SearchUserGroups
+				sc.handlerFunc = SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
 
 				So(sentLimit, ShouldEqual, 10)

+ 0 - 92
pkg/api/user_group.go

@@ -1,92 +0,0 @@
-package api
-
-import (
-	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/middleware"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/util"
-)
-
-// POST /api/user-groups
-func CreateUserGroup(c *middleware.Context, cmd m.CreateUserGroupCommand) Response {
-	cmd.OrgId = c.OrgId
-	if err := bus.Dispatch(&cmd); err != nil {
-		if err == m.ErrUserGroupNameTaken {
-			return ApiError(409, "User Group name taken", err)
-		}
-		return ApiError(500, "Failed to create User Group", err)
-	}
-
-	return Json(200, &util.DynMap{
-		"userGroupId": cmd.Result.Id,
-		"message":     "User Group created",
-	})
-}
-
-// PUT /api/user-groups/:userGroupId
-func UpdateUserGroup(c *middleware.Context, cmd m.UpdateUserGroupCommand) Response {
-	cmd.Id = c.ParamsInt64(":userGroupId")
-	if err := bus.Dispatch(&cmd); err != nil {
-		if err == m.ErrUserGroupNameTaken {
-			return ApiError(400, "User Group name taken", err)
-		}
-		return ApiError(500, "Failed to update User Group", err)
-	}
-
-	return ApiSuccess("User Group updated")
-}
-
-// DELETE /api/user-groups/:userGroupId
-func DeleteUserGroupById(c *middleware.Context) Response {
-	if err := bus.Dispatch(&m.DeleteUserGroupCommand{Id: c.ParamsInt64(":userGroupId")}); err != nil {
-		if err == m.ErrUserGroupNotFound {
-			return ApiError(404, "Failed to delete User Group. ID not found", nil)
-		}
-		return ApiError(500, "Failed to update User Group", err)
-	}
-	return ApiSuccess("User Group deleted")
-}
-
-// GET /api/user-groups/search
-func SearchUserGroups(c *middleware.Context) Response {
-	perPage := c.QueryInt("perpage")
-	if perPage <= 0 {
-		perPage = 1000
-	}
-	page := c.QueryInt("page")
-	if page < 1 {
-		page = 1
-	}
-
-	query := m.SearchUserGroupsQuery{
-		Query: c.Query("query"),
-		Name:  c.Query("name"),
-		Page:  page,
-		Limit: perPage,
-		OrgId: c.OrgId,
-	}
-
-	if err := bus.Dispatch(&query); err != nil {
-		return ApiError(500, "Failed to search User Groups", err)
-	}
-
-	query.Result.Page = page
-	query.Result.PerPage = perPage
-
-	return Json(200, query.Result)
-}
-
-// GET /api/user-groups/:userGroupId
-func GetUserGroupById(c *middleware.Context) Response {
-	query := m.GetUserGroupByIdQuery{Id: c.ParamsInt64(":userGroupId")}
-
-	if err := bus.Dispatch(&query); err != nil {
-		if err == m.ErrUserGroupNotFound {
-			return ApiError(404, "User Group not found", err)
-		}
-
-		return ApiError(500, "Failed to get User Group", err)
-	}
-
-	return Json(200, &query.Result)
-}

+ 0 - 44
pkg/api/user_group_members.go

@@ -1,44 +0,0 @@
-package api
-
-import (
-	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/middleware"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/util"
-)
-
-// GET /api/user-groups/:userGroupId/members
-func GetUserGroupMembers(c *middleware.Context) Response {
-	query := m.GetUserGroupMembersQuery{UserGroupId: c.ParamsInt64(":userGroupId")}
-
-	if err := bus.Dispatch(&query); err != nil {
-		return ApiError(500, "Failed to get User Group Members", err)
-	}
-
-	return Json(200, query.Result)
-}
-
-// POST /api/user-groups/:userGroupId/members
-func AddUserGroupMember(c *middleware.Context, cmd m.AddUserGroupMemberCommand) Response {
-	cmd.UserGroupId = c.ParamsInt64(":userGroupId")
-	cmd.OrgId = c.OrgId
-
-	if err := bus.Dispatch(&cmd); err != nil {
-		if err == m.ErrUserGroupMemberAlreadyAdded {
-			return ApiError(400, "User is already added to this user group", err)
-		}
-		return ApiError(500, "Failed to add Member to User Group", err)
-	}
-
-	return Json(200, &util.DynMap{
-		"message": "Member added to User Group",
-	})
-}
-
-// DELETE /api/user-groups/:userGroupId/members/:userId
-func RemoveUserGroupMember(c *middleware.Context) Response {
-	if err := bus.Dispatch(&m.RemoveUserGroupMemberCommand{UserGroupId: c.ParamsInt64(":userGroupId"), UserId: c.ParamsInt64(":userId")}); err != nil {
-		return ApiError(500, "Failed to remove Member from User Group", err)
-	}
-	return ApiSuccess("User Group Member removed")
-}

+ 25 - 10
pkg/cmd/grafana-server/main.go

@@ -14,8 +14,8 @@ import (
 	"net/http"
 	_ "net/http/pprof"
 
+	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/metrics"
-	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 
 	_ "github.com/grafana/grafana/pkg/services/alerting/conditions"
@@ -30,7 +30,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/tsdb/testdata"
 )
 
-var version = "4.6.0"
+var version = "5.0.0"
 var commit = "NA"
 var buildstamp string
 var build_date string
@@ -40,9 +40,6 @@ var homePath = flag.String("homepath", "", "path to grafana install/home path, d
 var pidFile = flag.String("pidfile", "", "path to pid file")
 var exitChan = make(chan int)
 
-func init() {
-}
-
 func main() {
 	v := flag.Bool("v", false, "prints current version and exits")
 	profile := flag.Bool("profile", false, "Turn on pprof profiling")
@@ -82,12 +79,28 @@ func main() {
 	setting.BuildStamp = buildstampInt64
 
 	metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
-
+	shutdownCompleted := make(chan int)
 	server := NewGrafanaServer()
-	server.Start()
+
+	go listenToSystemSignals(server, shutdownCompleted)
+
+	go func() {
+		code := 0
+		if err := server.Start(); err != nil {
+			log.Error2("Startup failed", "error", err)
+			code = 1
+		}
+
+		exitChan <- code
+	}()
+
+	code := <-shutdownCompleted
+	log.Info2("Grafana shutdown completed.", "code", code)
+	log.Close()
+	os.Exit(code)
 }
 
-func listenToSystemSignals(server models.GrafanaServer) {
+func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int) {
 	signalChan := make(chan os.Signal, 1)
 	ignoreChan := make(chan os.Signal, 1)
 	code := 0
@@ -97,10 +110,12 @@ func listenToSystemSignals(server models.GrafanaServer) {
 
 	select {
 	case sig := <-signalChan:
-		// Stops trace if profiling has been enabled
-		trace.Stop()
+		trace.Stop() // Stops trace if profiling has been enabled
 		server.Shutdown(0, fmt.Sprintf("system signal: %s", sig))
+		shutdownCompleted <- 0
 	case code = <-exitChan:
+		trace.Stop() // Stops trace if profiling has been enabled
 		server.Shutdown(code, "startup error")
+		shutdownCompleted <- code
 	}
 }

+ 17 - 27
pkg/cmd/grafana-server/server.go

@@ -11,7 +11,6 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
 	"github.com/grafana/grafana/pkg/services/provisioning"
 
 	"golang.org/x/sync/errgroup"
@@ -20,7 +19,6 @@ import (
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/metrics"
-	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/services/alerting"
 	"github.com/grafana/grafana/pkg/services/cleanup"
@@ -33,7 +31,7 @@ import (
 	"github.com/grafana/grafana/pkg/tracing"
 )
 
-func NewGrafanaServer() models.GrafanaServer {
+func NewGrafanaServer() *GrafanaServerImpl {
 	rootCtx, shutdownFn := context.WithCancel(context.Background())
 	childRoutines, childCtx := errgroup.WithContext(rootCtx)
 
@@ -54,9 +52,7 @@ type GrafanaServerImpl struct {
 	httpServer *api.HttpServer
 }
 
-func (g *GrafanaServerImpl) Start() {
-	go listenToSystemSignals(g)
-
+func (g *GrafanaServerImpl) Start() error {
 	g.initLogging()
 	g.writePIDFile()
 
@@ -68,17 +64,13 @@ func (g *GrafanaServerImpl) Start() {
 	social.NewOAuthService()
 	plugins.Init()
 
-	if err := provisioning.StartUp(setting.DatasourcesPath); err != nil {
-		logger.Error("Failed to provision Grafana from config", "error", err)
-		g.Shutdown(1, "Startup failed")
-		return
+	if err := provisioning.Init(g.context, setting.HomePath, setting.Cfg); err != nil {
+		return fmt.Errorf("Failed to provision Grafana from config. error: %v", err)
 	}
 
 	closer, err := tracing.Init(setting.Cfg)
 	if err != nil {
-		g.log.Error("Tracing settings is not valid", "error", err)
-		g.Shutdown(1, "Startup failed")
-		return
+		return fmt.Errorf("Tracing settings is not valid. error: %v", err)
 	}
 	defer closer.Close()
 
@@ -93,13 +85,12 @@ func (g *GrafanaServerImpl) Start() {
 	g.childRoutines.Go(func() error { return cleanUpService.Run(g.context) })
 
 	if err = notifications.Init(); err != nil {
-		g.log.Error("Notification service failed to initialize", "error", err)
-		g.Shutdown(1, "Startup failed")
-		return
+		return fmt.Errorf("Notification service failed to initialize. error: %v", err)
 	}
 
-	SendSystemdNotification("READY=1")
-	g.startHttpServer()
+	sendSystemdNotification("READY=1")
+
+	return g.startHttpServer()
 }
 
 func initSql() {
@@ -123,16 +114,16 @@ func (g *GrafanaServerImpl) initLogging() {
 	setting.LogConfigurationInfo()
 }
 
-func (g *GrafanaServerImpl) startHttpServer() {
+func (g *GrafanaServerImpl) startHttpServer() error {
 	g.httpServer = api.NewHttpServer()
 
 	err := g.httpServer.Start(g.context)
 
 	if err != nil {
-		g.log.Error("Fail to start server", "error", err)
-		g.Shutdown(1, "Startup failed")
-		return
+		return fmt.Errorf("Fail to start server. error: %v", err)
 	}
+
+	return nil
 }
 
 func (g *GrafanaServerImpl) Shutdown(code int, reason string) {
@@ -145,10 +136,9 @@ func (g *GrafanaServerImpl) Shutdown(code int, reason string) {
 
 	g.shutdownFn()
 	err = g.childRoutines.Wait()
-
-	g.log.Info("Shutdown completed", "reason", err)
-	log.Close()
-	os.Exit(code)
+	if err != nil && err != context.Canceled {
+		g.log.Error("Server shutdown completed with an error", "error", err)
+	}
 }
 
 func (g *GrafanaServerImpl) writePIDFile() {
@@ -173,7 +163,7 @@ func (g *GrafanaServerImpl) writePIDFile() {
 	g.log.Info("Writing PID file", "path", *pidFile, "pid", pid)
 }
 
-func SendSystemdNotification(state string) error {
+func sendSystemdNotification(state string) error {
 	notifySocket := os.Getenv("NOTIFY_SOCKET")
 
 	if notifySocket == "" {

+ 5 - 0
pkg/components/renderer/renderer.go

@@ -31,6 +31,7 @@ type RenderOpts struct {
 	OrgRole        models.RoleType
 	Timezone       string
 	IsAlertContext bool
+	Encoding       string
 }
 
 var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
@@ -103,6 +104,10 @@ func RenderToPng(params *RenderOpts) (string, error) {
 		"renderKey=" + renderKey,
 	}
 
+	if params.Encoding != "" {
+		cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", params.Encoding)}, cmdArgs...)
+	}
+
 	cmd := exec.Command(binPath, cmdArgs...)
 	stdout, err := cmd.StdoutPipe()
 

+ 8 - 8
pkg/models/dashboard_acl.go

@@ -24,7 +24,7 @@ func (p PermissionType) String() string {
 
 // Typed errors
 var (
-	ErrDashboardAclInfoMissing           = errors.New("User id and user group 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.")
 )
 
@@ -34,10 +34,10 @@ type DashboardAcl struct {
 	OrgId       int64
 	DashboardId int64
 
-	UserId      int64
-	UserGroupId int64
-	Role        *RoleType // pointer to be nullable
-	Permission  PermissionType
+	UserId     int64
+	TeamId     int64
+	Role       *RoleType // pointer to be nullable
+	Permission PermissionType
 
 	Created time.Time
 	Updated time.Time
@@ -54,8 +54,8 @@ type DashboardAclInfoDTO struct {
 	UserId         int64          `json:"userId"`
 	UserLogin      string         `json:"userLogin"`
 	UserEmail      string         `json:"userEmail"`
-	UserGroupId    int64          `json:"userGroupId"`
-	UserGroup      string         `json:"userGroup"`
+	TeamId         int64          `json:"teamId"`
+	Team           string         `json:"team"`
 	Role           *RoleType      `json:"role,omitempty"`
 	Permission     PermissionType `json:"permission"`
 	PermissionName string         `json:"permissionName"`
@@ -74,7 +74,7 @@ type SetDashboardAclCommand struct {
 	DashboardId int64
 	OrgId       int64
 	UserId      int64
-	UserGroupId int64
+	TeamId      int64
 	Permission  PermissionType
 
 	Result DashboardAcl

+ 10 - 6
pkg/models/dashboards.go

@@ -11,12 +11,14 @@ import (
 
 // Typed errors
 var (
-	ErrDashboardNotFound               = errors.New("Dashboard not found")
-	ErrDashboardSnapshotNotFound       = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameNameExists     = errors.New("A dashboard with the same name already exists")
-	ErrDashboardVersionMismatch        = errors.New("The dashboard has been changed by someone else")
-	ErrDashboardTitleEmpty             = errors.New("Dashboard title cannot be empty")
-	ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
+	ErrDashboardNotFound                 = errors.New("Dashboard not found")
+	ErrDashboardSnapshotNotFound         = errors.New("Dashboard snapshot not found")
+	ErrDashboardWithSameNameExists       = errors.New("A dashboard with the same name already exists")
+	ErrDashboardVersionMismatch          = errors.New("The dashboard has been changed by someone else")
+	ErrDashboardTitleEmpty               = errors.New("Dashboard title cannot be empty")
+	ErrDashboardFolderCannotHaveParent   = errors.New("A Dashboard Folder cannot be added to another folder")
+	ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
+	ErrDashboardFailedToUpdateAlertData  = errors.New("Failed to save alert data")
 )
 
 type UpdatePluginDashboardError struct {
@@ -156,6 +158,8 @@ type SaveDashboardCommand struct {
 	FolderId     int64            `json:"folderId"`
 	IsFolder     bool             `json:"isFolder"`
 
+	UpdatedAt time.Time
+
 	Result *Dashboard
 }
 

+ 0 - 6
pkg/models/server.go

@@ -1,6 +0,0 @@
-package models
-
-type GrafanaServer interface {
-	Start()
-	Shutdown(code int, reason string)
-}

+ 68 - 0
pkg/models/team.go

@@ -0,0 +1,68 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrTeamNotFound  = errors.New("Team not found")
+	ErrTeamNameTaken = errors.New("Team name is taken")
+)
+
+// Team model
+type Team struct {
+	Id    int64  `json:"id"`
+	OrgId int64  `json:"orgId"`
+	Name  string `json:"name"`
+
+	Created time.Time `json:"created"`
+	Updated time.Time `json:"updated"`
+}
+
+// ---------------------
+// COMMANDS
+
+type CreateTeamCommand struct {
+	Name  string `json:"name" binding:"Required"`
+	OrgId int64  `json:"-"`
+
+	Result Team `json:"-"`
+}
+
+type UpdateTeamCommand struct {
+	Id   int64
+	Name string
+}
+
+type DeleteTeamCommand struct {
+	Id int64
+}
+
+type GetTeamByIdQuery struct {
+	Id     int64
+	Result *Team
+}
+
+type GetTeamsByUserQuery struct {
+	UserId int64   `json:"userId"`
+	Result []*Team `json:"teams"`
+}
+
+type SearchTeamsQuery struct {
+	Query string
+	Name  string
+	Limit int
+	Page  int
+	OrgId int64
+
+	Result SearchTeamQueryResult
+}
+
+type SearchTeamQueryResult struct {
+	TotalCount int64   `json:"totalCount"`
+	Teams      []*Team `json:"teams"`
+	Page       int     `json:"page"`
+	PerPage    int     `json:"perPage"`
+}

+ 55 - 0
pkg/models/team_member.go

@@ -0,0 +1,55 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrTeamMemberAlreadyAdded = errors.New("User is already added to this team")
+)
+
+// TeamMember model
+type TeamMember struct {
+	Id     int64
+	OrgId  int64
+	TeamId int64
+	UserId int64
+
+	Created time.Time
+	Updated time.Time
+}
+
+// ---------------------
+// COMMANDS
+
+type AddTeamMemberCommand struct {
+	UserId int64 `json:"userId" binding:"Required"`
+	OrgId  int64 `json:"-"`
+	TeamId int64 `json:"-"`
+}
+
+type RemoveTeamMemberCommand struct {
+	UserId int64
+	TeamId int64
+}
+
+// ----------------------
+// QUERIES
+
+type GetTeamMembersQuery struct {
+	TeamId int64
+	Result []*TeamMemberDTO
+}
+
+// ----------------------
+// Projections and DTOs
+
+type TeamMemberDTO struct {
+	OrgId  int64  `json:"orgId"`
+	TeamId int64  `json:"teamId"`
+	UserId int64  `json:"userId"`
+	Email  string `json:"email"`
+	Login  string `json:"login"`
+}

+ 0 - 68
pkg/models/user_group.go

@@ -1,68 +0,0 @@
-package models
-
-import (
-	"errors"
-	"time"
-)
-
-// Typed errors
-var (
-	ErrUserGroupNotFound  = errors.New("User Group not found")
-	ErrUserGroupNameTaken = errors.New("User Group name is taken")
-)
-
-// UserGroup model
-type UserGroup struct {
-	Id    int64  `json:"id"`
-	OrgId int64  `json:"orgId"`
-	Name  string `json:"name"`
-
-	Created time.Time `json:"created"`
-	Updated time.Time `json:"updated"`
-}
-
-// ---------------------
-// COMMANDS
-
-type CreateUserGroupCommand struct {
-	Name  string `json:"name" binding:"Required"`
-	OrgId int64  `json:"-"`
-
-	Result UserGroup `json:"-"`
-}
-
-type UpdateUserGroupCommand struct {
-	Id   int64
-	Name string
-}
-
-type DeleteUserGroupCommand struct {
-	Id int64
-}
-
-type GetUserGroupByIdQuery struct {
-	Id     int64
-	Result *UserGroup
-}
-
-type GetUserGroupsByUserQuery struct {
-	UserId int64        `json:"userId"`
-	Result []*UserGroup `json:"userGroups"`
-}
-
-type SearchUserGroupsQuery struct {
-	Query string
-	Name  string
-	Limit int
-	Page  int
-	OrgId int64
-
-	Result SearchUserGroupQueryResult
-}
-
-type SearchUserGroupQueryResult struct {
-	TotalCount int64        `json:"totalCount"`
-	UserGroups []*UserGroup `json:"userGroups"`
-	Page       int          `json:"page"`
-	PerPage    int          `json:"perPage"`
-}

+ 0 - 55
pkg/models/user_group_member.go

@@ -1,55 +0,0 @@
-package models
-
-import (
-	"errors"
-	"time"
-)
-
-// Typed errors
-var (
-	ErrUserGroupMemberAlreadyAdded = errors.New("User is already added to this user group")
-)
-
-// UserGroupMember model
-type UserGroupMember struct {
-	Id          int64
-	OrgId       int64
-	UserGroupId int64
-	UserId      int64
-
-	Created time.Time
-	Updated time.Time
-}
-
-// ---------------------
-// COMMANDS
-
-type AddUserGroupMemberCommand struct {
-	UserId      int64 `json:"userId" binding:"Required"`
-	OrgId       int64 `json:"-"`
-	UserGroupId int64 `json:"-"`
-}
-
-type RemoveUserGroupMemberCommand struct {
-	UserId      int64
-	UserGroupId int64
-}
-
-// ----------------------
-// QUERIES
-
-type GetUserGroupMembersQuery struct {
-	UserGroupId int64
-	Result      []*UserGroupMemberDTO
-}
-
-// ----------------------
-// Projections and DTOs
-
-type UserGroupMemberDTO struct {
-	OrgId       int64  `json:"orgId"`
-	UserGroupId int64  `json:"userGroupId"`
-	UserId      int64  `json:"userId"`
-	Email       string `json:"email"`
-	Login       string `json:"login"`
-}

+ 82 - 0
pkg/services/dashboards/dashboards.go

@@ -0,0 +1,82 @@
+package dashboards
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
+)
+
+type Repository interface {
+	SaveDashboard(*SaveDashboardItem) (*models.Dashboard, error)
+}
+
+var repositoryInstance Repository
+
+func GetRepository() Repository {
+	return repositoryInstance
+}
+
+func SetRepository(rep Repository) {
+	repositoryInstance = rep
+}
+
+type SaveDashboardItem struct {
+	TitleLower string
+	OrgId      int64
+	Folder     string
+	UpdatedAt  time.Time
+	UserId     int64
+	Message    string
+	Overwrite  bool
+	Dashboard  *models.Dashboard
+}
+
+type DashboardRepository struct{}
+
+func (dr *DashboardRepository) SaveDashboard(json *SaveDashboardItem) (*models.Dashboard, error) {
+	dashboard := json.Dashboard
+
+	if dashboard.Title == "" {
+		return nil, models.ErrDashboardTitleEmpty
+	}
+
+	validateAlertsCmd := alerting.ValidateDashboardAlertsCommand{
+		OrgId:     json.OrgId,
+		Dashboard: dashboard,
+	}
+
+	if err := bus.Dispatch(&validateAlertsCmd); err != nil {
+		return nil, models.ErrDashboardContainsInvalidAlertData
+	}
+
+	cmd := models.SaveDashboardCommand{
+		Dashboard: dashboard.Data,
+		Message:   json.Message,
+		OrgId:     json.OrgId,
+		Overwrite: json.Overwrite,
+		UserId:    json.UserId,
+	}
+
+	if !json.UpdatedAt.IsZero() {
+		cmd.UpdatedAt = json.UpdatedAt
+	}
+
+	err := bus.Dispatch(&cmd)
+	if err != nil {
+		return nil, err
+	}
+
+	alertCmd := alerting.UpdateDashboardAlertsCommand{
+		OrgId:     json.OrgId,
+		UserId:    json.UserId,
+		Dashboard: cmd.Result,
+	}
+
+	if err := bus.Dispatch(&alertCmd); err != nil {
+		return nil, models.ErrDashboardFailedToUpdateAlertData
+	}
+
+	return cmd.Result, nil
+}

+ 10 - 10
pkg/services/guardian/guardian.go

@@ -11,7 +11,7 @@ type DashboardGuardian struct {
 	dashId int64
 	orgId  int64
 	acl    []*m.DashboardAclInfoDTO
-	groups []*m.UserGroup
+	groups []*m.Team
 	log    log.Logger
 }
 
@@ -55,7 +55,7 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
 		orgRole = m.ROLE_VIEWER
 	}
 
-	userGroupAclItems := []*m.DashboardAclInfoDTO{}
+	teamAclItems := []*m.DashboardAclInfoDTO{}
 
 	for _, p := range acl {
 		// user match
@@ -71,26 +71,26 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
 		}
 
 		// remember this rule for later
-		if p.UserGroupId > 0 {
-			userGroupAclItems = append(userGroupAclItems, p)
+		if p.TeamId > 0 {
+			teamAclItems = append(teamAclItems, p)
 		}
 	}
 
 	// do we have group rules?
-	if len(userGroupAclItems) == 0 {
+	if len(teamAclItems) == 0 {
 		return false, nil
 	}
 
 	// load groups
-	userGroups, err := g.getUserGroups()
+	teams, err := g.getTeams()
 	if err != nil {
 		return false, err
 	}
 
 	// evalute group rules
 	for _, p := range acl {
-		for _, ug := range userGroups {
-			if ug.Id == p.UserGroupId && p.Permission >= permission {
+		for _, ug := range teams {
+			if ug.Id == p.TeamId && p.Permission >= permission {
 				return true, nil
 			}
 		}
@@ -114,12 +114,12 @@ func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
 	return g.acl, nil
 }
 
-func (g *DashboardGuardian) getUserGroups() ([]*m.UserGroup, error) {
+func (g *DashboardGuardian) getTeams() ([]*m.Team, error) {
 	if g.groups != nil {
 		return g.groups, nil
 	}
 
-	query := m.GetUserGroupsByUserQuery{UserId: g.user.UserId}
+	query := m.GetTeamsByUserQuery{UserId: g.user.UserId}
 	err := bus.Dispatch(&query)
 
 	g.groups = query.Result

+ 49 - 0
pkg/services/provisioning/dashboards/config_reader.go

@@ -0,0 +1,49 @@
+package dashboards
+
+import (
+	"io/ioutil"
+	"path/filepath"
+	"strings"
+
+	yaml "gopkg.in/yaml.v2"
+)
+
+type configReader struct {
+	path string
+}
+
+func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
+	files, err := ioutil.ReadDir(cr.path)
+	if err != nil {
+		return nil, err
+	}
+
+	var dashboards []*DashboardsAsConfig
+	for _, file := range files {
+		if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") {
+			continue
+		}
+
+		filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name()))
+		yamlFile, err := ioutil.ReadFile(filename)
+		if err != nil {
+			return nil, err
+		}
+
+		var datasource []*DashboardsAsConfig
+		err = yaml.Unmarshal(yamlFile, &datasource)
+		if err != nil {
+			return nil, err
+		}
+
+		dashboards = append(dashboards, datasource...)
+	}
+
+	for i := range dashboards {
+		if dashboards[i].OrgId == 0 {
+			dashboards[i].OrgId = 1
+		}
+	}
+
+	return dashboards, nil
+}

+ 62 - 0
pkg/services/provisioning/dashboards/config_reader_test.go

@@ -0,0 +1,62 @@
+package dashboards
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+var (
+	simpleDashboardConfig string = "./test-configs/dashboards-from-disk"
+	brokenConfigs         string = "./test-configs/broken-configs"
+)
+
+func TestDashboardsAsConfig(t *testing.T) {
+	Convey("Dashboards as configuration", t, func() {
+
+		Convey("Can read config file", func() {
+
+			cfgProvifer := configReader{path: simpleDashboardConfig}
+			cfg, err := cfgProvifer.readConfig()
+			if err != nil {
+				t.Fatalf("readConfig return an error %v", err)
+			}
+
+			So(len(cfg), ShouldEqual, 2)
+
+			ds := cfg[0]
+
+			So(ds.Name, ShouldEqual, "general dashboards")
+			So(ds.Type, ShouldEqual, "file")
+			So(ds.OrgId, ShouldEqual, 2)
+			So(ds.Folder, ShouldEqual, "developers")
+			So(ds.Editable, ShouldBeTrue)
+
+			So(len(ds.Options), ShouldEqual, 1)
+			So(ds.Options["folder"], ShouldEqual, "/var/lib/grafana/dashboards")
+
+			ds2 := cfg[1]
+
+			So(ds2.Name, ShouldEqual, "default")
+			So(ds2.Type, ShouldEqual, "file")
+			So(ds2.OrgId, ShouldEqual, 1)
+			So(ds2.Folder, ShouldEqual, "")
+			So(ds2.Editable, ShouldBeFalse)
+
+			So(len(ds2.Options), ShouldEqual, 1)
+			So(ds2.Options["folder"], ShouldEqual, "/var/lib/grafana/dashboards")
+		})
+
+		Convey("Should skip broken config files", func() {
+
+			cfgProvifer := configReader{path: brokenConfigs}
+			cfg, err := cfgProvifer.readConfig()
+			if err != nil {
+				t.Fatalf("readConfig return an error %v", err)
+			}
+
+			So(len(cfg), ShouldEqual, 0)
+
+		})
+	})
+}

+ 48 - 0
pkg/services/provisioning/dashboards/dashboard.go

@@ -0,0 +1,48 @@
+package dashboards
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/grafana/grafana/pkg/log"
+)
+
+type DashboardProvisioner struct {
+	cfgReader *configReader
+	log       log.Logger
+	ctx       context.Context
+}
+
+func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) {
+	d := &DashboardProvisioner{
+		cfgReader: &configReader{path: configDirectory},
+		log:       log.New("provisioning.dashboard"),
+		ctx:       ctx,
+	}
+
+	err := d.Provision(ctx)
+	return d, err
+}
+
+func (provider *DashboardProvisioner) Provision(ctx context.Context) error {
+	cfgs, err := provider.cfgReader.readConfig()
+	if err != nil {
+		return err
+	}
+
+	for _, cfg := range cfgs {
+		switch cfg.Type {
+		case "file":
+			fileReader, err := NewDashboardFileReader(cfg, provider.log.New("type", cfg.Type, "name", cfg.Name))
+			if err != nil {
+				return err
+			}
+
+			go fileReader.ReadAndListen(ctx)
+		default:
+			return fmt.Errorf("type %s is not supported", cfg.Type)
+		}
+	}
+
+	return nil
+}

+ 175 - 0
pkg/services/provisioning/dashboards/file_reader.go

@@ -0,0 +1,175 @@
+package dashboards
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/grafana/grafana/pkg/services/dashboards"
+
+	"github.com/grafana/grafana/pkg/bus"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/models"
+	gocache "github.com/patrickmn/go-cache"
+)
+
+type fileReader struct {
+	Cfg           *DashboardsAsConfig
+	Path          string
+	log           log.Logger
+	dashboardRepo dashboards.Repository
+	cache         *gocache.Cache
+}
+
+func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) {
+	path, ok := cfg.Options["folder"].(string)
+	if !ok {
+		return nil, fmt.Errorf("Failed to load dashboards. folder param is not a string")
+	}
+
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		log.Error("Cannot read directory", "error", err)
+	}
+
+	return &fileReader{
+		Cfg:           cfg,
+		Path:          path,
+		log:           log,
+		dashboardRepo: dashboards.GetRepository(),
+		cache:         gocache.New(5*time.Minute, 30*time.Minute),
+	}, nil
+}
+
+func (fr *fileReader) addCache(key string, json *dashboards.SaveDashboardItem) {
+	fr.cache.Add(key, json, time.Minute*10)
+}
+
+func (fr *fileReader) getCache(key string) (*dashboards.SaveDashboardItem, bool) {
+	obj, exist := fr.cache.Get(key)
+	if !exist {
+		return nil, exist
+	}
+
+	dash, ok := obj.(*dashboards.SaveDashboardItem)
+	if !ok {
+		return nil, ok
+	}
+
+	return dash, ok
+}
+
+func (fr *fileReader) ReadAndListen(ctx context.Context) error {
+	ticker := time.NewTicker(time.Second * 3)
+
+	if err := fr.walkFolder(); err != nil {
+		fr.log.Error("failed to search for dashboards", "error", err)
+	}
+
+	running := false
+
+	for {
+		select {
+		case <-ticker.C:
+			if !running { // avoid walking the filesystem in parallel. incase fs is very slow.
+				running = true
+				go func() {
+					fr.walkFolder()
+					running = false
+				}()
+			}
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}
+
+func (fr *fileReader) walkFolder() error {
+	if _, err := os.Stat(fr.Path); err != nil {
+		if os.IsNotExist(err) {
+			return err
+		}
+	}
+
+	return filepath.Walk(fr.Path, func(path string, fileInfo os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if fileInfo.IsDir() {
+			if strings.HasPrefix(fileInfo.Name(), ".") {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+
+		if !strings.HasSuffix(fileInfo.Name(), ".json") {
+			return nil
+		}
+
+		cachedDashboard, exist := fr.getCache(path)
+		if exist && cachedDashboard.UpdatedAt == fileInfo.ModTime() {
+			return nil
+		}
+
+		dash, err := fr.readDashboardFromFile(path)
+		if err != nil {
+			fr.log.Error("failed to load dashboard from ", "file", path, "error", err)
+			return nil
+		}
+
+		cmd := &models.GetDashboardQuery{Slug: dash.Dashboard.Slug}
+		err = bus.Dispatch(cmd)
+
+		// if we dont have the dashboard in the db, save it!
+		if err == models.ErrDashboardNotFound {
+			fr.log.Debug("saving new dashboard", "file", path)
+			_, err = fr.dashboardRepo.SaveDashboard(dash)
+			return err
+		}
+
+		if err != nil {
+			fr.log.Error("failed to query for dashboard", "slug", dash.Dashboard.Slug, "error", err)
+			return nil
+		}
+
+		// break if db version is newer then fil version
+		if cmd.Result.Updated.Unix() >= fileInfo.ModTime().Unix() {
+			return nil
+		}
+
+		fr.log.Debug("loading dashboard from disk into database.", "file", path)
+		_, err = fr.dashboardRepo.SaveDashboard(dash)
+		return err
+	})
+}
+
+func (fr *fileReader) readDashboardFromFile(path string) (*dashboards.SaveDashboardItem, error) {
+	reader, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close()
+
+	data, err := simplejson.NewFromReader(reader)
+	if err != nil {
+		return nil, err
+	}
+
+	stat, err := os.Stat(path)
+	if err != nil {
+		return nil, err
+	}
+
+	dash, err := createDashboardJson(data, stat.ModTime(), fr.Cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	fr.addCache(path, dash)
+
+	return dash, nil
+}

+ 137 - 0
pkg/services/provisioning/dashboards/file_reader_test.go

@@ -0,0 +1,137 @@
+package dashboards
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+
+	"github.com/grafana/grafana/pkg/log"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+var (
+	defaultDashboards string = "./test-dashboards/folder-one"
+	brokenDashboards  string = "./test-dashboards/broken-dashboards"
+	oneDashboard      string = "./test-dashboards/one-dashboard"
+
+	fakeRepo *fakeDashboardRepo
+)
+
+func TestDashboardFileReader(t *testing.T) {
+	Convey("Reading dashboards from disk", t, func() {
+		bus.ClearBusHandlers()
+		fakeRepo = &fakeDashboardRepo{}
+
+		bus.AddHandler("test", mockGetDashboardQuery)
+		dashboards.SetRepository(fakeRepo)
+		logger := log.New("test.logger")
+
+		cfg := &DashboardsAsConfig{
+			Name:    "Default",
+			Type:    "file",
+			OrgId:   1,
+			Folder:  "",
+			Options: map[string]interface{}{},
+		}
+
+		Convey("Can read default dashboard", func() {
+			cfg.Options["folder"] = defaultDashboards
+
+			reader, err := NewDashboardFileReader(cfg, logger)
+			So(err, ShouldBeNil)
+
+			err = reader.walkFolder()
+			So(err, ShouldBeNil)
+
+			So(len(fakeRepo.inserted), ShouldEqual, 2)
+		})
+
+		Convey("Should not update dashboards when db is newer", func() {
+			cfg.Options["folder"] = oneDashboard
+
+			fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{
+				Updated: time.Now().Add(time.Hour),
+				Slug:    "grafana",
+			})
+
+			reader, err := NewDashboardFileReader(cfg, logger)
+			So(err, ShouldBeNil)
+
+			err = reader.walkFolder()
+			So(err, ShouldBeNil)
+
+			So(len(fakeRepo.inserted), ShouldEqual, 0)
+		})
+
+		Convey("Can read default dashboard and replace old version in database", func() {
+			cfg.Options["folder"] = oneDashboard
+
+			stat, _ := os.Stat(oneDashboard + "/dashboard1.json")
+
+			fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{
+				Updated: stat.ModTime().AddDate(0, 0, -1),
+				Slug:    "grafana",
+			})
+
+			reader, err := NewDashboardFileReader(cfg, logger)
+			So(err, ShouldBeNil)
+
+			err = reader.walkFolder()
+			So(err, ShouldBeNil)
+
+			So(len(fakeRepo.inserted), ShouldEqual, 1)
+		})
+
+		Convey("Invalid configuration should return error", func() {
+			cfg := &DashboardsAsConfig{
+				Name:   "Default",
+				Type:   "file",
+				OrgId:  1,
+				Folder: "",
+			}
+
+			_, err := NewDashboardFileReader(cfg, logger)
+			So(err, ShouldNotBeNil)
+		})
+
+		Convey("Broken dashboards should not cause error", func() {
+			cfg := &DashboardsAsConfig{
+				Name:   "Default",
+				Type:   "file",
+				OrgId:  1,
+				Folder: "",
+				Options: map[string]interface{}{
+					"folder": brokenDashboards,
+				},
+			}
+
+			_, err := NewDashboardFileReader(cfg, logger)
+			So(err, ShouldBeNil)
+		})
+	})
+}
+
+type fakeDashboardRepo struct {
+	inserted     []*dashboards.SaveDashboardItem
+	getDashboard []*models.Dashboard
+}
+
+func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*models.Dashboard, error) {
+	repo.inserted = append(repo.inserted, json)
+	return json.Dashboard, nil
+}
+
+func mockGetDashboardQuery(cmd *models.GetDashboardQuery) error {
+	for _, d := range fakeRepo.getDashboard {
+		if d.Slug == cmd.Slug {
+			cmd.Result = d
+			return nil
+		}
+	}
+
+	return models.ErrDashboardNotFound
+}

+ 6 - 0
pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml

@@ -0,0 +1,6 @@
+# - name: 'default'
+#   org_id: 1
+#   folder: ''
+#   type: file
+#   options:
+#     folder: /var/lib/grafana/dashboards

+ 12 - 0
pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml

@@ -0,0 +1,12 @@
+- name: 'general dashboards'
+  org_id: 2
+  folder: 'developers'
+  editable: true
+  type: file
+  options:
+    folder: /var/lib/grafana/dashboards
+
+- name: 'default'
+  type: file
+  options:
+    folder: /var/lib/grafana/dashboards

+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/empty-json.json


+ 6 - 0
pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/invalid.json

@@ -0,0 +1,6 @@
+[]
+{
+    "title": "Grafana",
+    
+  }
+  

+ 173 - 0
pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json

@@ -0,0 +1,173 @@
+{
+    "title": "Grafana",
+    "tags": [],
+    "style": "dark",
+    "timezone": "browser",
+    "editable": true,
+    "rows": [
+      {
+        "title": "New row",
+        "height": "150px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 1,
+            "span": 12,
+            "editable": true,
+            "type": "text",
+            "mode": "html",
+            "content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
+            "style": {},
+            "title": "Welcome to"
+          }
+        ]
+      },
+      {
+        "title": "Welcome to Grafana",
+        "height": "210px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 2,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a>  (Must read!)\n      </li>\n    </ul>\n  </div>\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n      </li>\n    </ul>\n  </div>\n</div>",
+            "style": {},
+            "title": "Documentation Links"
+          },
+          {
+            "id": 3,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span12\">\n    <ul>\n      <li>Ctrl+S saves the current dashboard</li>\n      <li>Ctrl+F Opens the dashboard finder</li>\n      <li>Ctrl+H Hide/show row controls</li>\n      <li>Click and drag graph title to move panel</li>\n      <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n      <li>Click the colored icon in the legend to change series color</li>\n      <li>Ctrl or Shift + Click legend name to hide other series</li>\n    </ul>\n  </div>\n</div>\n",
+            "style": {},
+            "title": "Tips & Shortcuts"
+          }
+        ]
+      },
+      {
+        "title": "test",
+        "height": "250px",
+        "editable": true,
+        "collapse": false,
+        "panels": [
+          {
+            "id": 4,
+            "span": 12,
+            "type": "graph",
+            "x-axis": true,
+            "y-axis": true,
+            "scale": 1,
+            "y_formats": [
+              "short",
+              "short"
+            ],
+            "grid": {
+              "max": null,
+              "min": null,
+              "leftMax": null,
+              "rightMax": null,
+              "leftMin": null,
+              "rightMin": null,
+              "threshold1": null,
+              "threshold2": null,
+              "threshold1Color": "rgba(216, 200, 27, 0.27)",
+              "threshold2Color": "rgba(234, 112, 112, 0.22)"
+            },
+            "resolution": 100,
+            "lines": true,
+            "fill": 1,
+            "linewidth": 2,
+            "dashes": false,
+            "dashLength": 10,
+            "spaceLength": 10,
+            "points": false,
+            "pointradius": 5,
+            "bars": false,
+            "stack": true,
+            "spyable": true,
+            "options": false,
+            "legend": {
+              "show": true,
+              "values": false,
+              "min": false,
+              "max": false,
+              "current": false,
+              "total": false,
+              "avg": false
+            },
+            "interactive": true,
+            "legend_counts": true,
+            "timezone": "browser",
+            "percentage": false,
+            "nullPointMode": "connected",
+            "steppedLine": false,
+            "tooltip": {
+              "value_type": "cumulative",
+              "query_as_alias": true
+            },
+            "targets": [
+              {
+                "target": "randomWalk('random walk')",
+                "function": "mean",
+                "column": "value"
+              }
+            ],
+            "aliasColors": {},
+            "aliasYAxis": {},
+            "title": "First Graph (click title to edit)",
+            "datasource": "graphite",
+            "renderer": "flot",
+            "annotate": {
+              "enable": false
+            }
+          }
+        ]
+      }
+    ],
+    "nav": [
+      {
+        "type": "timepicker",
+        "collapse": false,
+        "enable": true,
+        "status": "Stable",
+        "time_options": [
+          "5m",
+          "15m",
+          "1h",
+          "6h",
+          "12h",
+          "24h",
+          "2d",
+          "7d",
+          "30d"
+        ],
+        "refresh_intervals": [
+          "5s",
+          "10s",
+          "30s",
+          "1m",
+          "5m",
+          "15m",
+          "30m",
+          "1h",
+          "2h",
+          "1d"
+        ],
+        "now": true
+      }
+    ],
+    "time": {
+      "from": "now-6h",
+      "to": "now"
+    },
+    "templating": {
+      "list": []
+    },
+    "version": 5
+  }
+  

+ 173 - 0
pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json

@@ -0,0 +1,173 @@
+{
+    "title": "Grafana",
+    "tags": [],
+    "style": "dark",
+    "timezone": "browser",
+    "editable": true,
+    "rows": [
+      {
+        "title": "New row",
+        "height": "150px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 1,
+            "span": 12,
+            "editable": true,
+            "type": "text",
+            "mode": "html",
+            "content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
+            "style": {},
+            "title": "Welcome to"
+          }
+        ]
+      },
+      {
+        "title": "Welcome to Grafana",
+        "height": "210px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 2,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a>  (Must read!)\n      </li>\n    </ul>\n  </div>\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n      </li>\n    </ul>\n  </div>\n</div>",
+            "style": {},
+            "title": "Documentation Links"
+          },
+          {
+            "id": 3,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span12\">\n    <ul>\n      <li>Ctrl+S saves the current dashboard</li>\n      <li>Ctrl+F Opens the dashboard finder</li>\n      <li>Ctrl+H Hide/show row controls</li>\n      <li>Click and drag graph title to move panel</li>\n      <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n      <li>Click the colored icon in the legend to change series color</li>\n      <li>Ctrl or Shift + Click legend name to hide other series</li>\n    </ul>\n  </div>\n</div>\n",
+            "style": {},
+            "title": "Tips & Shortcuts"
+          }
+        ]
+      },
+      {
+        "title": "test",
+        "height": "250px",
+        "editable": true,
+        "collapse": false,
+        "panels": [
+          {
+            "id": 4,
+            "span": 12,
+            "type": "graph",
+            "x-axis": true,
+            "y-axis": true,
+            "scale": 1,
+            "y_formats": [
+              "short",
+              "short"
+            ],
+            "grid": {
+              "max": null,
+              "min": null,
+              "leftMax": null,
+              "rightMax": null,
+              "leftMin": null,
+              "rightMin": null,
+              "threshold1": null,
+              "threshold2": null,
+              "threshold1Color": "rgba(216, 200, 27, 0.27)",
+              "threshold2Color": "rgba(234, 112, 112, 0.22)"
+            },
+            "resolution": 100,
+            "lines": true,
+            "fill": 1,
+            "linewidth": 2,
+            "dashes": false,
+            "dashLength": 10,
+            "spaceLength": 10,
+            "points": false,
+            "pointradius": 5,
+            "bars": false,
+            "stack": true,
+            "spyable": true,
+            "options": false,
+            "legend": {
+              "show": true,
+              "values": false,
+              "min": false,
+              "max": false,
+              "current": false,
+              "total": false,
+              "avg": false
+            },
+            "interactive": true,
+            "legend_counts": true,
+            "timezone": "browser",
+            "percentage": false,
+            "nullPointMode": "connected",
+            "steppedLine": false,
+            "tooltip": {
+              "value_type": "cumulative",
+              "query_as_alias": true
+            },
+            "targets": [
+              {
+                "target": "randomWalk('random walk')",
+                "function": "mean",
+                "column": "value"
+              }
+            ],
+            "aliasColors": {},
+            "aliasYAxis": {},
+            "title": "First Graph (click title to edit)",
+            "datasource": "graphite",
+            "renderer": "flot",
+            "annotate": {
+              "enable": false
+            }
+          }
+        ]
+      }
+    ],
+    "nav": [
+      {
+        "type": "timepicker",
+        "collapse": false,
+        "enable": true,
+        "status": "Stable",
+        "time_options": [
+          "5m",
+          "15m",
+          "1h",
+          "6h",
+          "12h",
+          "24h",
+          "2d",
+          "7d",
+          "30d"
+        ],
+        "refresh_intervals": [
+          "5s",
+          "10s",
+          "30s",
+          "1m",
+          "5m",
+          "15m",
+          "30m",
+          "1h",
+          "2h",
+          "1d"
+        ],
+        "now": true
+      }
+    ],
+    "time": {
+      "from": "now-6h",
+      "to": "now"
+    },
+    "templating": {
+      "list": []
+    },
+    "version": 5
+  }
+  

+ 173 - 0
pkg/services/provisioning/dashboards/test-dashboards/one-dashboard/dashboard1.json

@@ -0,0 +1,173 @@
+{
+    "title": "Grafana",
+    "tags": [],
+    "style": "dark",
+    "timezone": "browser",
+    "editable": true,
+    "rows": [
+      {
+        "title": "New row",
+        "height": "150px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 1,
+            "span": 12,
+            "editable": true,
+            "type": "text",
+            "mode": "html",
+            "content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
+            "style": {},
+            "title": "Welcome to"
+          }
+        ]
+      },
+      {
+        "title": "Welcome to Grafana",
+        "height": "210px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 2,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a>  (Must read!)\n      </li>\n    </ul>\n  </div>\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n      </li>\n    </ul>\n  </div>\n</div>",
+            "style": {},
+            "title": "Documentation Links"
+          },
+          {
+            "id": 3,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span12\">\n    <ul>\n      <li>Ctrl+S saves the current dashboard</li>\n      <li>Ctrl+F Opens the dashboard finder</li>\n      <li>Ctrl+H Hide/show row controls</li>\n      <li>Click and drag graph title to move panel</li>\n      <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n      <li>Click the colored icon in the legend to change series color</li>\n      <li>Ctrl or Shift + Click legend name to hide other series</li>\n    </ul>\n  </div>\n</div>\n",
+            "style": {},
+            "title": "Tips & Shortcuts"
+          }
+        ]
+      },
+      {
+        "title": "test",
+        "height": "250px",
+        "editable": true,
+        "collapse": false,
+        "panels": [
+          {
+            "id": 4,
+            "span": 12,
+            "type": "graph",
+            "x-axis": true,
+            "y-axis": true,
+            "scale": 1,
+            "y_formats": [
+              "short",
+              "short"
+            ],
+            "grid": {
+              "max": null,
+              "min": null,
+              "leftMax": null,
+              "rightMax": null,
+              "leftMin": null,
+              "rightMin": null,
+              "threshold1": null,
+              "threshold2": null,
+              "threshold1Color": "rgba(216, 200, 27, 0.27)",
+              "threshold2Color": "rgba(234, 112, 112, 0.22)"
+            },
+            "resolution": 100,
+            "lines": true,
+            "fill": 1,
+            "linewidth": 2,
+            "dashes": false,
+            "dashLength": 10,
+            "spaceLength": 10,
+            "points": false,
+            "pointradius": 5,
+            "bars": false,
+            "stack": true,
+            "spyable": true,
+            "options": false,
+            "legend": {
+              "show": true,
+              "values": false,
+              "min": false,
+              "max": false,
+              "current": false,
+              "total": false,
+              "avg": false
+            },
+            "interactive": true,
+            "legend_counts": true,
+            "timezone": "browser",
+            "percentage": false,
+            "nullPointMode": "connected",
+            "steppedLine": false,
+            "tooltip": {
+              "value_type": "cumulative",
+              "query_as_alias": true
+            },
+            "targets": [
+              {
+                "target": "randomWalk('random walk')",
+                "function": "mean",
+                "column": "value"
+              }
+            ],
+            "aliasColors": {},
+            "aliasYAxis": {},
+            "title": "First Graph (click title to edit)",
+            "datasource": "graphite",
+            "renderer": "flot",
+            "annotate": {
+              "enable": false
+            }
+          }
+        ]
+      }
+    ],
+    "nav": [
+      {
+        "type": "timepicker",
+        "collapse": false,
+        "enable": true,
+        "status": "Stable",
+        "time_options": [
+          "5m",
+          "15m",
+          "1h",
+          "6h",
+          "12h",
+          "24h",
+          "2d",
+          "7d",
+          "30d"
+        ],
+        "refresh_intervals": [
+          "5s",
+          "10s",
+          "30s",
+          "1m",
+          "5m",
+          "15m",
+          "30m",
+          "1h",
+          "2h",
+          "1d"
+        ],
+        "now": true
+      }
+    ],
+    "time": {
+      "from": "now-6h",
+      "to": "now"
+    },
+    "templating": {
+      "list": []
+    },
+    "version": 5
+  }
+  

+ 38 - 0
pkg/services/provisioning/dashboards/types.go

@@ -0,0 +1,38 @@
+package dashboards
+
+import (
+	"strings"
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+
+	"github.com/grafana/grafana/pkg/models"
+)
+
+type DashboardsAsConfig struct {
+	Name     string                 `json:"name" yaml:"name"`
+	Type     string                 `json:"type" yaml:"type"`
+	OrgId    int64                  `json:"org_id" yaml:"org_id"`
+	Folder   string                 `json:"folder" yaml:"folder"`
+	Editable bool                   `json:"editable" yaml:"editable"`
+	Options  map[string]interface{} `json:"options" yaml:"options"`
+}
+
+func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig) (*dashboards.SaveDashboardItem, error) {
+
+	dash := &dashboards.SaveDashboardItem{}
+	dash.Dashboard = models.NewDashboardFromJson(data)
+	dash.TitleLower = strings.ToLower(dash.Dashboard.Title)
+	dash.UpdatedAt = lastModified
+	dash.Overwrite = true
+	dash.OrgId = cfg.OrgId
+	dash.Folder = cfg.Folder
+	dash.Dashboard.Data.Set("editable", cfg.Editable)
+
+	if dash.Dashboard.Title == "" {
+		return nil, models.ErrDashboardTitleEmpty
+	}
+
+	return dash, nil
+}

+ 10 - 4
pkg/services/provisioning/datasources/datasources.go

@@ -118,13 +118,19 @@ func (configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) {
 				return nil, err
 			}
 
-			datasources = append(datasources, datasource)
+			if datasource != nil {
+				datasources = append(datasources, datasource)
+			}
 		}
 	}
 
 	defaultCount := 0
-	for _, cfg := range datasources {
-		for _, ds := range cfg.Datasources {
+	for i := range datasources {
+		if datasources[i].Datasources == nil {
+			continue
+		}
+
+		for _, ds := range datasources[i].Datasources {
 			if ds.OrgId == 0 {
 				ds.OrgId = 1
 			}
@@ -137,7 +143,7 @@ func (configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) {
 			}
 		}
 
-		for _, ds := range cfg.DeleteDatasources {
+		for _, ds := range datasources[i].DeleteDatasources {
 			if ds.OrgId == 0 {
 				ds.OrgId = 1
 			}

+ 48 - 0
pkg/services/provisioning/datasources/test-configs/broken-yaml/commented.yaml

@@ -0,0 +1,48 @@
+# # list of datasources that should be deleted from the database
+#delete_datasources:
+#   - name: Graphite
+#     org_id: 1
+
+# # list of datasources to insert/update depending
+# # whats available in the datbase
+#datasources:
+#   # <string, required> name of the datasource. Required
+# - name: Graphite
+#   # <string, required> datasource type. Required
+#   type: graphite
+#   # <string, required> access mode. direct or proxy. Required
+#   access: proxy
+#   # <int> org id. will default to org_id 1 if not specified
+#   org_id: 1
+#   # <string> url
+#   url: http://localhost:8080
+#   # <string> database password, if used
+#   password:
+#   # <string> database user, if used
+#   user:
+#   # <string> database name, if used
+#   database:
+#   # <bool> enable/disable basic auth
+#   basic_auth:
+#   # <string> basic auth username
+#   basic_auth_user:
+#   # <string> basic auth password
+#   basic_auth_password:
+#   # <bool> enable/disable with credentials headers
+#   with_credentials:
+#   # <bool> mark as default datasource. Max one per org
+#   is_default:
+#   # <map> fields that will be converted to json and stored in json_data
+#   json_data:
+#      graphiteVersion: "1.1"
+#      tlsAuth: true
+#      tlsAuthWithCACert: true
+#   # <string> json object of data that will be encrypted.
+#   secure_json_data:
+#     tlsCACert: "..."
+#     tlsClientCert: "..."
+#     tlsClientKey: "..."
+#   version: 1
+#   # <bool> allow users to edit datasources from the UI.
+#   editable: false
+

+ 27 - 6
pkg/services/provisioning/provisioning.go

@@ -1,14 +1,35 @@
 package provisioning
 
 import (
-	"github.com/grafana/grafana/pkg/log"
+	"context"
+	"path"
+	"path/filepath"
+
+	"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
 	"github.com/grafana/grafana/pkg/services/provisioning/datasources"
+	ini "gopkg.in/ini.v1"
 )
 
-var (
-	logger log.Logger = log.New("services.provisioning")
-)
+func Init(ctx context.Context, homePath string, cfg *ini.File) error {
+	provisioningPath := makeAbsolute(cfg.Section("paths").Key("provisioning").String(), homePath)
+
+	datasourcePath := path.Join(provisioningPath, "datasources")
+	if err := datasources.Provision(datasourcePath); err != nil {
+		return err
+	}
+
+	dashboardPath := path.Join(provisioningPath, "dashboards")
+	_, err := dashboards.Provision(ctx, dashboardPath)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
 
-func StartUp(datasourcePath string) error {
-	return datasources.Provision(datasourcePath)
+func makeAbsolute(path string, root string) string {
+	if filepath.IsAbs(path) {
+		return path
+	}
+	return filepath.Join(root, path)
 }

+ 1 - 0
pkg/services/search/handlers_test.go

@@ -12,6 +12,7 @@ func TestSearch(t *testing.T) {
 
 	Convey("Given search query", t, func() {
 		query := Query{Limit: 2000, SignedInUser: &m.SignedInUser{IsGrafanaAdmin: true}}
+
 		bus.AddHandler("test", func(query *FindPersistedDashboardsQuery) error {
 			query.Result = HitList{
 				&Hit{Id: 16, Title: "CCAA", Type: "dash-db", Tags: []string{"BB", "AA"}},

+ 5 - 0
pkg/services/sqlstore/dashboard.go

@@ -84,6 +84,11 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 		} else {
 			dash.Version++
 			dash.Data.Set("version", dash.Version)
+
+			if !cmd.UpdatedAt.IsZero() {
+				dash.Updated = cmd.UpdatedAt
+			}
+
 			affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
 		}
 

+ 13 - 13
pkg/services/sqlstore/dashboard_acl.go

@@ -24,7 +24,7 @@ func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error {
 		}
 
 		for _, item := range cmd.Items {
-			if item.UserId == 0 && item.UserGroupId == 0 && !item.Role.IsValid() {
+			if item.UserId == 0 && item.TeamId == 0 && !item.Role.IsValid() {
 				return m.ErrDashboardAclInfoMissing
 			}
 
@@ -32,7 +32,7 @@ func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error {
 				return m.ErrDashboardPermissionDashboardEmpty
 			}
 
-			sess.Nullable("user_id", "user_group_id")
+			sess.Nullable("user_id", "team_id")
 			if _, err := sess.Insert(item); err != nil {
 				return err
 			}
@@ -49,7 +49,7 @@ func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error {
 
 func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		if cmd.UserId == 0 && cmd.UserGroupId == 0 {
+		if cmd.UserId == 0 && cmd.TeamId == 0 {
 			return m.ErrDashboardAclInfoMissing
 		}
 
@@ -57,7 +57,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
 			return m.ErrDashboardPermissionDashboardEmpty
 		}
 
-		if res, err := sess.Query("SELECT 1 from "+dialect.Quote("dashboard_acl")+" WHERE dashboard_id =? and (user_group_id=? or user_id=?)", cmd.DashboardId, cmd.UserGroupId, cmd.UserId); err != nil {
+		if res, err := sess.Query("SELECT 1 from "+dialect.Quote("dashboard_acl")+" WHERE dashboard_id =? and (team_id=? or user_id=?)", cmd.DashboardId, cmd.TeamId, cmd.UserId); err != nil {
 			return err
 		} else if len(res) == 1 {
 
@@ -66,7 +66,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
 				Updated:    time.Now(),
 			}
 
-			if _, err := sess.Cols("updated", "permission").Where("dashboard_id =? and (user_group_id=? or user_id=?)", cmd.DashboardId, cmd.UserGroupId, cmd.UserId).Update(&entity); err != nil {
+			if _, err := sess.Cols("updated", "permission").Where("dashboard_id =? and (team_id=? or user_id=?)", cmd.DashboardId, cmd.TeamId, cmd.UserId).Update(&entity); err != nil {
 				return err
 			}
 
@@ -75,7 +75,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
 
 		entity := m.DashboardAcl{
 			OrgId:       cmd.OrgId,
-			UserGroupId: cmd.UserGroupId,
+			TeamId:      cmd.TeamId,
 			UserId:      cmd.UserId,
 			Created:     time.Now(),
 			Updated:     time.Now(),
@@ -89,8 +89,8 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
 			cols = append(cols, "user_id")
 		}
 
-		if cmd.UserGroupId != 0 {
-			cols = append(cols, "user_group_id")
+		if cmd.TeamId != 0 {
+			cols = append(cols, "team_id")
 		}
 
 		_, err := sess.Cols(cols...).Insert(&entity)
@@ -138,17 +138,17 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		da.org_id,
 		da.dashboard_id,
 		da.user_id,
-		da.user_group_id,
+		da.team_id,
 		da.permission,
 		da.role,
 		da.created,
 		da.updated,
 		u.login AS user_login,
 		u.email AS user_email,
-		ug.name AS user_group
+		ug.name AS team
   FROM` + dialect.Quote("dashboard_acl") + ` as da
 		LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
-		LEFT OUTER JOIN user_group ug on ug.id = da.user_group_id
+		LEFT OUTER JOIN team ug on ug.id = da.team_id
 	WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
 
 	-- Also include default permission if has_acl = 0
@@ -159,14 +159,14 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 			da.org_id,
 			da.dashboard_id,
 			da.user_id,
-			da.user_group_id,
+			da.team_id,
 			da.permission,
 			da.role,
 			da.created,
 			da.updated,
 			'' as user_login,
 			'' as user_email,
-			'' as user_group
+			'' as team
 			FROM dashboard_acl as da,
         dashboard as dash
         LEFT JOIN dashboard folder on dash.folder_id = folder.id

+ 11 - 11
pkg/services/sqlstore/dashboard_acl_test.go

@@ -16,7 +16,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
 			savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
 			childDash := insertTestDashboard("2 test dash", 1, savedFolder.Id, false, "prod", "webapp")
 
-			Convey("When adding dashboard permission with userId and userGroupId set to 0", func() {
+			Convey("When adding dashboard permission with userId and teamId set to 0", func() {
 				err := SetDashboardAcl(&m.SetDashboardAclCommand{
 					OrgId:       1,
 					DashboardId: savedFolder.Id,
@@ -175,15 +175,15 @@ func TestDashboardAclDataAccess(t *testing.T) {
 				})
 			})
 
-			Convey("Given a user group", func() {
-				group1 := m.CreateUserGroupCommand{Name: "group1 name", OrgId: 1}
-				err := CreateUserGroup(&group1)
+			Convey("Given a team", func() {
+				group1 := m.CreateTeamCommand{Name: "group1 name", OrgId: 1}
+				err := CreateTeam(&group1)
 				So(err, ShouldBeNil)
 
-				Convey("Should be able to add a user permission for a user group", func() {
+				Convey("Should be able to add a user permission for a team", func() {
 					setDashAclCmd := m.SetDashboardAclCommand{
 						OrgId:       1,
-						UserGroupId: group1.Result.Id,
+						TeamId:      group1.Result.Id,
 						DashboardId: savedFolder.Id,
 						Permission:  m.PERMISSION_EDIT,
 					}
@@ -196,9 +196,9 @@ func TestDashboardAclDataAccess(t *testing.T) {
 					So(err, ShouldBeNil)
 					So(q1.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
 					So(q1.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
-					So(q1.Result[0].UserGroupId, ShouldEqual, group1.Result.Id)
+					So(q1.Result[0].TeamId, ShouldEqual, group1.Result.Id)
 
-					Convey("Should be able to delete an existing permission for a user group", func() {
+					Convey("Should be able to delete an existing permission for a team", func() {
 						err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{
 							OrgId: 1,
 							AclId: setDashAclCmd.Result.Id,
@@ -212,10 +212,10 @@ func TestDashboardAclDataAccess(t *testing.T) {
 					})
 				})
 
-				Convey("Should be able to update an existing permission for a user group", func() {
+				Convey("Should be able to update an existing permission for a team", func() {
 					err := SetDashboardAcl(&m.SetDashboardAclCommand{
 						OrgId:       1,
-						UserGroupId: group1.Result.Id,
+						TeamId:      group1.Result.Id,
 						DashboardId: savedFolder.Id,
 						Permission:  m.PERMISSION_ADMIN,
 					})
@@ -227,7 +227,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
 					So(len(q3.Result), ShouldEqual, 1)
 					So(q3.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
 					So(q3.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
-					So(q3.Result[0].UserGroupId, ShouldEqual, group1.Result.Id)
+					So(q3.Result[0].TeamId, ShouldEqual, group1.Result.Id)
 				})
 
 			})

+ 3 - 3
pkg/services/sqlstore/migrations/dashboard_acl.go

@@ -10,7 +10,7 @@ func addDashboardAclMigrations(mg *Migrator) {
 			{Name: "org_id", Type: DB_BigInt},
 			{Name: "dashboard_id", Type: DB_BigInt},
 			{Name: "user_id", Type: DB_BigInt, Nullable: true},
-			{Name: "user_group_id", Type: DB_BigInt, Nullable: true},
+			{Name: "team_id", Type: DB_BigInt, Nullable: true},
 			{Name: "permission", Type: DB_SmallInt, Default: "4"},
 			{Name: "role", Type: DB_Varchar, Length: 20, Nullable: true},
 			{Name: "created", Type: DB_DateTime, Nullable: false},
@@ -19,7 +19,7 @@ func addDashboardAclMigrations(mg *Migrator) {
 		Indices: []*Index{
 			{Cols: []string{"dashboard_id"}},
 			{Cols: []string{"dashboard_id", "user_id"}, Type: UniqueIndex},
-			{Cols: []string{"dashboard_id", "user_group_id"}, Type: UniqueIndex},
+			{Cols: []string{"dashboard_id", "team_id"}, Type: UniqueIndex},
 		},
 	}
 
@@ -28,7 +28,7 @@ func addDashboardAclMigrations(mg *Migrator) {
 	//-------  indexes ------------------
 	mg.AddMigration("add unique index dashboard_acl_dashboard_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[0]))
 	mg.AddMigration("add unique index dashboard_acl_dashboard_id_user_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[1]))
-	mg.AddMigration("add unique index dashboard_acl_dashboard_id_group_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[2]))
+	mg.AddMigration("add unique index dashboard_acl_dashboard_id_team_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[2]))
 
 	const rawSQL = `
 INSERT INTO dashboard_acl

+ 1 - 1
pkg/services/sqlstore/migrations/migrations.go

@@ -26,7 +26,7 @@ func AddMigrations(mg *Migrator) {
 	addAnnotationMig(mg)
 	addTestDataMigrations(mg)
 	addDashboardVersionMigration(mg)
-	addUserGroupMigrations(mg)
+	addTeamMigrations(mg)
 	addDashboardAclMigrations(mg)
 	addTagMigration(mg)
 }

+ 13 - 13
pkg/services/sqlstore/migrations/user_group_mig.go → pkg/services/sqlstore/migrations/team_mig.go

@@ -2,9 +2,9 @@ package migrations
 
 import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 
-func addUserGroupMigrations(mg *Migrator) {
-	userGroupV1 := Table{
-		Name: "user_group",
+func addTeamMigrations(mg *Migrator) {
+	teamV1 := Table{
+		Name: "team",
 		Columns: []*Column{
 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
 			{Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false},
@@ -18,31 +18,31 @@ func addUserGroupMigrations(mg *Migrator) {
 		},
 	}
 
-	mg.AddMigration("create user group table", NewAddTableMigration(userGroupV1))
+	mg.AddMigration("create team table", NewAddTableMigration(teamV1))
 
 	//-------  indexes ------------------
-	mg.AddMigration("add index user_group.org_id", NewAddIndexMigration(userGroupV1, userGroupV1.Indices[0]))
-	mg.AddMigration("add unique index user_group_org_id_name", NewAddIndexMigration(userGroupV1, userGroupV1.Indices[1]))
+	mg.AddMigration("add index team.org_id", NewAddIndexMigration(teamV1, teamV1.Indices[0]))
+	mg.AddMigration("add unique index team_org_id_name", NewAddIndexMigration(teamV1, teamV1.Indices[1]))
 
-	userGroupMemberV1 := Table{
-		Name: "user_group_member",
+	teamMemberV1 := Table{
+		Name: "team_member",
 		Columns: []*Column{
 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
 			{Name: "org_id", Type: DB_BigInt},
-			{Name: "user_group_id", Type: DB_BigInt},
+			{Name: "team_id", Type: DB_BigInt},
 			{Name: "user_id", Type: DB_BigInt},
 			{Name: "created", Type: DB_DateTime, Nullable: false},
 			{Name: "updated", Type: DB_DateTime, Nullable: false},
 		},
 		Indices: []*Index{
 			{Cols: []string{"org_id"}},
-			{Cols: []string{"org_id", "user_group_id", "user_id"}, Type: UniqueIndex},
+			{Cols: []string{"org_id", "team_id", "user_id"}, Type: UniqueIndex},
 		},
 	}
 
-	mg.AddMigration("create user group member table", NewAddTableMigration(userGroupMemberV1))
+	mg.AddMigration("create team member table", NewAddTableMigration(teamMemberV1))
 
 	//-------  indexes ------------------
-	mg.AddMigration("add index user_group_member.org_id", NewAddIndexMigration(userGroupMemberV1, userGroupMemberV1.Indices[0]))
-	mg.AddMigration("add unique index user_group_member_org_id_user_group_id_user_id", NewAddIndexMigration(userGroupMemberV1, userGroupMemberV1.Indices[1]))
+	mg.AddMigration("add index team_member.org_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[0]))
+	mg.AddMigration("add unique index team_member_org_id_team_id_user_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[1]))
 }

+ 1 - 1
pkg/services/sqlstore/org_users.go

@@ -91,7 +91,7 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
 		deletes := []string{
 			"DELETE FROM org_user WHERE org_id=? and user_id=?",
 			"DELETE FROM dashboard_acl WHERE org_id=? and user_id = ?",
-			"DELETE FROM user_group_member WHERE org_id=? and user_id = ?",
+			"DELETE FROM team_member WHERE org_id=? and user_id = ?",
 		}
 
 		for _, sql := range deletes {

+ 1 - 1
pkg/services/sqlstore/search_builder.go

@@ -179,7 +179,7 @@ func (sb *SearchBuilder) buildSearchWhereClause() {
 			SELECT distinct d.id AS DashboardId
 			FROM dashboard AS d
 	      		LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id
-	      		LEFT JOIN user_group_member as ugm on ugm.user_group_id =  da.user_group_id
+	      		LEFT JOIN team_member as ugm on ugm.team_id =  da.team_id
 	      		LEFT JOIN org_user ou on ou.role = da.role
 			WHERE
 			  d.has_acl = 1 and

+ 2 - 0
pkg/services/sqlstore/sqlstore.go

@@ -12,6 +12,7 @@ import (
 	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/annotations"
+	"github.com/grafana/grafana/pkg/services/dashboards"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 	"github.com/grafana/grafana/pkg/setting"
@@ -100,6 +101,7 @@ func SetEngine(engine *xorm.Engine) (err error) {
 
 	// Init repo instances
 	annotations.SetRepository(&SqlAnnotationRepo{})
+	dashboards.SetRepository(&dashboards.DashboardRepository{})
 	return nil
 }
 

+ 233 - 0
pkg/services/sqlstore/team.go

@@ -0,0 +1,233 @@
+package sqlstore
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func init() {
+	bus.AddHandler("sql", CreateTeam)
+	bus.AddHandler("sql", UpdateTeam)
+	bus.AddHandler("sql", DeleteTeam)
+	bus.AddHandler("sql", SearchTeams)
+	bus.AddHandler("sql", GetTeamById)
+	bus.AddHandler("sql", GetTeamsByUser)
+
+	bus.AddHandler("sql", AddTeamMember)
+	bus.AddHandler("sql", RemoveTeamMember)
+	bus.AddHandler("sql", GetTeamMembers)
+}
+
+func CreateTeam(cmd *m.CreateTeamCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+
+		if isNameTaken, err := isTeamNameTaken(cmd.Name, 0, sess); err != nil {
+			return err
+		} else if isNameTaken {
+			return m.ErrTeamNameTaken
+		}
+
+		team := m.Team{
+			Name:    cmd.Name,
+			OrgId:   cmd.OrgId,
+			Created: time.Now(),
+			Updated: time.Now(),
+		}
+
+		_, err := sess.Insert(&team)
+
+		cmd.Result = team
+
+		return err
+	})
+}
+
+func UpdateTeam(cmd *m.UpdateTeamCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+
+		if isNameTaken, err := isTeamNameTaken(cmd.Name, cmd.Id, sess); err != nil {
+			return err
+		} else if isNameTaken {
+			return m.ErrTeamNameTaken
+		}
+
+		team := m.Team{
+			Name:    cmd.Name,
+			Updated: time.Now(),
+		}
+
+		affectedRows, err := sess.Id(cmd.Id).Update(&team)
+
+		if err != nil {
+			return err
+		}
+
+		if affectedRows == 0 {
+			return m.ErrTeamNotFound
+		}
+
+		return nil
+	})
+}
+
+func DeleteTeam(cmd *m.DeleteTeamCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.Id); err != nil {
+			return err
+		} else if len(res) != 1 {
+			return m.ErrTeamNotFound
+		}
+
+		deletes := []string{
+			"DELETE FROM team_member WHERE team_id = ?",
+			"DELETE FROM team WHERE id = ?",
+			"DELETE FROM dashboard_acl WHERE team_id = ?",
+		}
+
+		for _, sql := range deletes {
+			_, err := sess.Exec(sql, cmd.Id)
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func isTeamNameTaken(name string, existingId int64, sess *DBSession) (bool, error) {
+	var team m.Team
+	exists, err := sess.Where("name=?", name).Get(&team)
+
+	if err != nil {
+		return false, nil
+	}
+
+	if exists && existingId != team.Id {
+		return true, nil
+	}
+
+	return false, nil
+}
+
+func SearchTeams(query *m.SearchTeamsQuery) error {
+	query.Result = m.SearchTeamQueryResult{
+		Teams: make([]*m.Team, 0),
+	}
+	queryWithWildcards := "%" + query.Query + "%"
+
+	sess := x.Table("team")
+	sess.Where("org_id=?", query.OrgId)
+
+	if query.Query != "" {
+		sess.Where("name LIKE ?", queryWithWildcards)
+	}
+	if query.Name != "" {
+		sess.Where("name=?", query.Name)
+	}
+	sess.Asc("name")
+
+	offset := query.Limit * (query.Page - 1)
+	sess.Limit(query.Limit, offset)
+	sess.Cols("id", "name")
+	if err := sess.Find(&query.Result.Teams); err != nil {
+		return err
+	}
+
+	team := m.Team{}
+
+	countSess := x.Table("team")
+	if query.Query != "" {
+		countSess.Where("name LIKE ?", queryWithWildcards)
+	}
+	if query.Name != "" {
+		countSess.Where("name=?", query.Name)
+	}
+	count, err := countSess.Count(&team)
+	query.Result.TotalCount = count
+
+	return err
+}
+
+func GetTeamById(query *m.GetTeamByIdQuery) error {
+	var team m.Team
+	exists, err := x.Id(query.Id).Get(&team)
+	if err != nil {
+		return err
+	}
+
+	if !exists {
+		return m.ErrTeamNotFound
+	}
+
+	query.Result = &team
+	return nil
+}
+
+func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
+	query.Result = make([]*m.Team, 0)
+
+	sess := x.Table("team")
+	sess.Join("INNER", "team_member", "team.id=team_member.team_id")
+	sess.Where("team_member.user_id=?", query.UserId)
+
+	err := sess.Find(&query.Result)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		if res, err := sess.Query("SELECT 1 from team_member WHERE team_id=? and user_id=?", cmd.TeamId, cmd.UserId); err != nil {
+			return err
+		} else if len(res) == 1 {
+			return m.ErrTeamMemberAlreadyAdded
+		}
+
+		if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.TeamId); err != nil {
+			return err
+		} else if len(res) != 1 {
+			return m.ErrTeamNotFound
+		}
+
+		entity := m.TeamMember{
+			OrgId:   cmd.OrgId,
+			TeamId:  cmd.TeamId,
+			UserId:  cmd.UserId,
+			Created: time.Now(),
+			Updated: time.Now(),
+		}
+
+		_, err := sess.Insert(&entity)
+		return err
+	})
+}
+
+func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		var rawSql = "DELETE FROM team_member WHERE team_id=? and user_id=?"
+		_, err := sess.Exec(rawSql, cmd.TeamId, cmd.UserId)
+		if err != nil {
+			return err
+		}
+
+		return err
+	})
+}
+
+func GetTeamMembers(query *m.GetTeamMembersQuery) error {
+	query.Result = make([]*m.TeamMemberDTO, 0)
+	sess := x.Table("team_member")
+	sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user")))
+	sess.Where("team_member.team_id=?", query.TeamId)
+	sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login")
+	sess.Asc("user.login", "user.email")
+
+	err := sess.Find(&query.Result)
+	return err
+}

+ 114 - 0
pkg/services/sqlstore/team_test.go

@@ -0,0 +1,114 @@
+package sqlstore
+
+import (
+	"fmt"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func TestTeamCommandsAndQueries(t *testing.T) {
+
+	Convey("Testing Team commands & queries", t, func() {
+		InitTestDB(t)
+
+		Convey("Given saved users and two teams", func() {
+			var userIds []int64
+			for i := 0; i < 5; i++ {
+				userCmd := &m.CreateUserCommand{
+					Email: fmt.Sprint("user", i, "@test.com"),
+					Name:  fmt.Sprint("user", i),
+					Login: fmt.Sprint("loginuser", i),
+				}
+				err := CreateUser(userCmd)
+				So(err, ShouldBeNil)
+				userIds = append(userIds, userCmd.Result.Id)
+			}
+
+			group1 := m.CreateTeamCommand{Name: "group1 name"}
+			group2 := m.CreateTeamCommand{Name: "group2 name"}
+
+			err := CreateTeam(&group1)
+			So(err, ShouldBeNil)
+			err = CreateTeam(&group2)
+			So(err, ShouldBeNil)
+
+			Convey("Should be able to create teams and add users", func() {
+				query := &m.SearchTeamsQuery{Name: "group1 name", Page: 1, Limit: 10}
+				err = SearchTeams(query)
+				So(err, ShouldBeNil)
+				So(query.Page, ShouldEqual, 1)
+
+				team1 := query.Result.Teams[0]
+				So(team1.Name, ShouldEqual, "group1 name")
+
+				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]})
+				So(err, ShouldBeNil)
+
+				q1 := &m.GetTeamMembersQuery{TeamId: team1.Id}
+				err = GetTeamMembers(q1)
+				So(err, ShouldBeNil)
+				So(q1.Result[0].TeamId, ShouldEqual, team1.Id)
+				So(q1.Result[0].Login, ShouldEqual, "loginuser0")
+			})
+
+			Convey("Should be able to search for teams", func() {
+				query := &m.SearchTeamsQuery{Query: "group", Page: 1}
+				err = SearchTeams(query)
+				So(err, ShouldBeNil)
+				So(len(query.Result.Teams), ShouldEqual, 2)
+				So(query.Result.TotalCount, ShouldEqual, 2)
+
+				query2 := &m.SearchTeamsQuery{Query: ""}
+				err = SearchTeams(query2)
+				So(err, ShouldBeNil)
+				So(len(query2.Result.Teams), ShouldEqual, 2)
+			})
+
+			Convey("Should be able to return all teams a user is member of", func() {
+				groupId := group2.Result.Id
+				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[0]})
+
+				query := &m.GetTeamsByUserQuery{UserId: userIds[0]}
+				err = GetTeamsByUser(query)
+				So(err, ShouldBeNil)
+				So(len(query.Result), ShouldEqual, 1)
+				So(query.Result[0].Name, ShouldEqual, "group2 name")
+			})
+
+			Convey("Should be able to remove users from a group", func() {
+				err = RemoveTeamMember(&m.RemoveTeamMemberCommand{TeamId: group1.Result.Id, UserId: userIds[0]})
+				So(err, ShouldBeNil)
+
+				q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id}
+				err = GetTeamMembers(q1)
+				So(err, ShouldBeNil)
+				So(len(q1.Result), ShouldEqual, 0)
+			})
+
+			Convey("Should be able to remove a group with users and permissions", func() {
+				groupId := group2.Result.Id
+				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[1]})
+				So(err, ShouldBeNil)
+				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[2]})
+				So(err, ShouldBeNil)
+				err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, TeamId: groupId})
+
+				err = DeleteTeam(&m.DeleteTeamCommand{Id: groupId})
+				So(err, ShouldBeNil)
+
+				query := &m.GetTeamByIdQuery{Id: groupId}
+				err = GetTeamById(query)
+				So(err, ShouldEqual, m.ErrTeamNotFound)
+
+				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
+				err = GetDashboardAclInfoList(permQuery)
+				So(err, ShouldBeNil)
+
+				So(len(permQuery.Result), ShouldEqual, 0)
+			})
+		})
+	})
+}

+ 1 - 1
pkg/services/sqlstore/user.go

@@ -442,7 +442,7 @@ func DeleteUser(cmd *m.DeleteUserCommand) error {
 			"DELETE FROM org_user WHERE user_id = ?",
 			"DELETE FROM dashboard_acl WHERE user_id = ?",
 			"DELETE FROM preferences WHERE user_id = ?",
-			"DELETE FROM user_group_member WHERE user_id = ?",
+			"DELETE FROM team_member WHERE user_id = ?",
 		}
 
 		for _, sql := range deletes {

+ 0 - 233
pkg/services/sqlstore/user_group.go

@@ -1,233 +0,0 @@
-package sqlstore
-
-import (
-	"fmt"
-	"time"
-
-	"github.com/grafana/grafana/pkg/bus"
-	m "github.com/grafana/grafana/pkg/models"
-)
-
-func init() {
-	bus.AddHandler("sql", CreateUserGroup)
-	bus.AddHandler("sql", UpdateUserGroup)
-	bus.AddHandler("sql", DeleteUserGroup)
-	bus.AddHandler("sql", SearchUserGroups)
-	bus.AddHandler("sql", GetUserGroupById)
-	bus.AddHandler("sql", GetUserGroupsByUser)
-
-	bus.AddHandler("sql", AddUserGroupMember)
-	bus.AddHandler("sql", RemoveUserGroupMember)
-	bus.AddHandler("sql", GetUserGroupMembers)
-}
-
-func CreateUserGroup(cmd *m.CreateUserGroupCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-
-		if isNameTaken, err := isUserGroupNameTaken(cmd.Name, 0, sess); err != nil {
-			return err
-		} else if isNameTaken {
-			return m.ErrUserGroupNameTaken
-		}
-
-		userGroup := m.UserGroup{
-			Name:    cmd.Name,
-			OrgId:   cmd.OrgId,
-			Created: time.Now(),
-			Updated: time.Now(),
-		}
-
-		_, err := sess.Insert(&userGroup)
-
-		cmd.Result = userGroup
-
-		return err
-	})
-}
-
-func UpdateUserGroup(cmd *m.UpdateUserGroupCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-
-		if isNameTaken, err := isUserGroupNameTaken(cmd.Name, cmd.Id, sess); err != nil {
-			return err
-		} else if isNameTaken {
-			return m.ErrUserGroupNameTaken
-		}
-
-		userGroup := m.UserGroup{
-			Name:    cmd.Name,
-			Updated: time.Now(),
-		}
-
-		affectedRows, err := sess.Id(cmd.Id).Update(&userGroup)
-
-		if err != nil {
-			return err
-		}
-
-		if affectedRows == 0 {
-			return m.ErrUserGroupNotFound
-		}
-
-		return nil
-	})
-}
-
-func DeleteUserGroup(cmd *m.DeleteUserGroupCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-		if res, err := sess.Query("SELECT 1 from user_group WHERE id=?", cmd.Id); err != nil {
-			return err
-		} else if len(res) != 1 {
-			return m.ErrUserGroupNotFound
-		}
-
-		deletes := []string{
-			"DELETE FROM user_group_member WHERE user_group_id = ?",
-			"DELETE FROM user_group WHERE id = ?",
-			"DELETE FROM dashboard_acl WHERE user_group_id = ?",
-		}
-
-		for _, sql := range deletes {
-			_, err := sess.Exec(sql, cmd.Id)
-			if err != nil {
-				return err
-			}
-		}
-		return nil
-	})
-}
-
-func isUserGroupNameTaken(name string, existingId int64, sess *DBSession) (bool, error) {
-	var userGroup m.UserGroup
-	exists, err := sess.Where("name=?", name).Get(&userGroup)
-
-	if err != nil {
-		return false, nil
-	}
-
-	if exists && existingId != userGroup.Id {
-		return true, nil
-	}
-
-	return false, nil
-}
-
-func SearchUserGroups(query *m.SearchUserGroupsQuery) error {
-	query.Result = m.SearchUserGroupQueryResult{
-		UserGroups: make([]*m.UserGroup, 0),
-	}
-	queryWithWildcards := "%" + query.Query + "%"
-
-	sess := x.Table("user_group")
-	sess.Where("org_id=?", query.OrgId)
-
-	if query.Query != "" {
-		sess.Where("name LIKE ?", queryWithWildcards)
-	}
-	if query.Name != "" {
-		sess.Where("name=?", query.Name)
-	}
-	sess.Asc("name")
-
-	offset := query.Limit * (query.Page - 1)
-	sess.Limit(query.Limit, offset)
-	sess.Cols("id", "name")
-	if err := sess.Find(&query.Result.UserGroups); err != nil {
-		return err
-	}
-
-	userGroup := m.UserGroup{}
-
-	countSess := x.Table("user_group")
-	if query.Query != "" {
-		countSess.Where("name LIKE ?", queryWithWildcards)
-	}
-	if query.Name != "" {
-		countSess.Where("name=?", query.Name)
-	}
-	count, err := countSess.Count(&userGroup)
-	query.Result.TotalCount = count
-
-	return err
-}
-
-func GetUserGroupById(query *m.GetUserGroupByIdQuery) error {
-	var userGroup m.UserGroup
-	exists, err := x.Id(query.Id).Get(&userGroup)
-	if err != nil {
-		return err
-	}
-
-	if !exists {
-		return m.ErrUserGroupNotFound
-	}
-
-	query.Result = &userGroup
-	return nil
-}
-
-func GetUserGroupsByUser(query *m.GetUserGroupsByUserQuery) error {
-	query.Result = make([]*m.UserGroup, 0)
-
-	sess := x.Table("user_group")
-	sess.Join("INNER", "user_group_member", "user_group.id=user_group_member.user_group_id")
-	sess.Where("user_group_member.user_id=?", query.UserId)
-
-	err := sess.Find(&query.Result)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func AddUserGroupMember(cmd *m.AddUserGroupMemberCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-		if res, err := sess.Query("SELECT 1 from user_group_member WHERE user_group_id=? and user_id=?", cmd.UserGroupId, cmd.UserId); err != nil {
-			return err
-		} else if len(res) == 1 {
-			return m.ErrUserGroupMemberAlreadyAdded
-		}
-
-		if res, err := sess.Query("SELECT 1 from user_group WHERE id=?", cmd.UserGroupId); err != nil {
-			return err
-		} else if len(res) != 1 {
-			return m.ErrUserGroupNotFound
-		}
-
-		entity := m.UserGroupMember{
-			OrgId:       cmd.OrgId,
-			UserGroupId: cmd.UserGroupId,
-			UserId:      cmd.UserId,
-			Created:     time.Now(),
-			Updated:     time.Now(),
-		}
-
-		_, err := sess.Insert(&entity)
-		return err
-	})
-}
-
-func RemoveUserGroupMember(cmd *m.RemoveUserGroupMemberCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-		var rawSql = "DELETE FROM user_group_member WHERE user_group_id=? and user_id=?"
-		_, err := sess.Exec(rawSql, cmd.UserGroupId, cmd.UserId)
-		if err != nil {
-			return err
-		}
-
-		return err
-	})
-}
-
-func GetUserGroupMembers(query *m.GetUserGroupMembersQuery) error {
-	query.Result = make([]*m.UserGroupMemberDTO, 0)
-	sess := x.Table("user_group_member")
-	sess.Join("INNER", "user", fmt.Sprintf("user_group_member.user_id=%s.id", x.Dialect().Quote("user")))
-	sess.Where("user_group_member.user_group_id=?", query.UserGroupId)
-	sess.Cols("user.org_id", "user_group_member.user_group_id", "user_group_member.user_id", "user.email", "user.login")
-	sess.Asc("user.login", "user.email")
-
-	err := sess.Find(&query.Result)
-	return err
-}

+ 0 - 114
pkg/services/sqlstore/user_group_test.go

@@ -1,114 +0,0 @@
-package sqlstore
-
-import (
-	"fmt"
-	"testing"
-
-	. "github.com/smartystreets/goconvey/convey"
-
-	m "github.com/grafana/grafana/pkg/models"
-)
-
-func TestUserGroupCommandsAndQueries(t *testing.T) {
-
-	Convey("Testing User Group commands & queries", t, func() {
-		InitTestDB(t)
-
-		Convey("Given saved users and two user groups", func() {
-			var userIds []int64
-			for i := 0; i < 5; i++ {
-				userCmd := &m.CreateUserCommand{
-					Email: fmt.Sprint("user", i, "@test.com"),
-					Name:  fmt.Sprint("user", i),
-					Login: fmt.Sprint("loginuser", i),
-				}
-				err := CreateUser(userCmd)
-				So(err, ShouldBeNil)
-				userIds = append(userIds, userCmd.Result.Id)
-			}
-
-			group1 := m.CreateUserGroupCommand{Name: "group1 name"}
-			group2 := m.CreateUserGroupCommand{Name: "group2 name"}
-
-			err := CreateUserGroup(&group1)
-			So(err, ShouldBeNil)
-			err = CreateUserGroup(&group2)
-			So(err, ShouldBeNil)
-
-			Convey("Should be able to create user groups and add users", func() {
-				query := &m.SearchUserGroupsQuery{Name: "group1 name", Page: 1, Limit: 10}
-				err = SearchUserGroups(query)
-				So(err, ShouldBeNil)
-				So(query.Page, ShouldEqual, 1)
-
-				userGroup1 := query.Result.UserGroups[0]
-				So(userGroup1.Name, ShouldEqual, "group1 name")
-
-				err = AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: userGroup1.Id, UserId: userIds[0]})
-				So(err, ShouldBeNil)
-
-				q1 := &m.GetUserGroupMembersQuery{UserGroupId: userGroup1.Id}
-				err = GetUserGroupMembers(q1)
-				So(err, ShouldBeNil)
-				So(q1.Result[0].UserGroupId, ShouldEqual, userGroup1.Id)
-				So(q1.Result[0].Login, ShouldEqual, "loginuser0")
-			})
-
-			Convey("Should be able to search for user groups", func() {
-				query := &m.SearchUserGroupsQuery{Query: "group", Page: 1}
-				err = SearchUserGroups(query)
-				So(err, ShouldBeNil)
-				So(len(query.Result.UserGroups), ShouldEqual, 2)
-				So(query.Result.TotalCount, ShouldEqual, 2)
-
-				query2 := &m.SearchUserGroupsQuery{Query: ""}
-				err = SearchUserGroups(query2)
-				So(err, ShouldBeNil)
-				So(len(query2.Result.UserGroups), ShouldEqual, 2)
-			})
-
-			Convey("Should be able to return all user groups a user is member of", func() {
-				groupId := group2.Result.Id
-				err := AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: groupId, UserId: userIds[0]})
-
-				query := &m.GetUserGroupsByUserQuery{UserId: userIds[0]}
-				err = GetUserGroupsByUser(query)
-				So(err, ShouldBeNil)
-				So(len(query.Result), ShouldEqual, 1)
-				So(query.Result[0].Name, ShouldEqual, "group2 name")
-			})
-
-			Convey("Should be able to remove users from a group", func() {
-				err = RemoveUserGroupMember(&m.RemoveUserGroupMemberCommand{UserGroupId: group1.Result.Id, UserId: userIds[0]})
-				So(err, ShouldBeNil)
-
-				q1 := &m.GetUserGroupMembersQuery{UserGroupId: group1.Result.Id}
-				err = GetUserGroupMembers(q1)
-				So(err, ShouldBeNil)
-				So(len(q1.Result), ShouldEqual, 0)
-			})
-
-			Convey("Should be able to remove a group with users and permissions", func() {
-				groupId := group2.Result.Id
-				err := AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: groupId, UserId: userIds[1]})
-				So(err, ShouldBeNil)
-				err = AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: groupId, UserId: userIds[2]})
-				So(err, ShouldBeNil)
-				err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, UserGroupId: groupId})
-
-				err = DeleteUserGroup(&m.DeleteUserGroupCommand{Id: groupId})
-				So(err, ShouldBeNil)
-
-				query := &m.GetUserGroupByIdQuery{Id: groupId}
-				err = GetUserGroupById(query)
-				So(err, ShouldEqual, m.ErrUserGroupNotFound)
-
-				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
-				err = GetDashboardAclInfoList(permQuery)
-				So(err, ShouldBeNil)
-
-				So(len(permQuery.Result), ShouldEqual, 0)
-			})
-		})
-	})
-}

+ 8 - 9
pkg/setting/setting.go

@@ -50,12 +50,12 @@ var (
 	BuildStamp   int64
 
 	// Paths
-	LogsPath        string
-	HomePath        string
-	DataPath        string
-	PluginsPath     string
-	DatasourcesPath string
-	CustomInitPath  = "conf/custom.ini"
+	LogsPath         string
+	HomePath         string
+	DataPath         string
+	PluginsPath      string
+	ProvisioningPath string
+	CustomInitPath   = "conf/custom.ini"
 
 	// Log settings.
 	LogModes   []string
@@ -474,8 +474,7 @@ func NewConfigContext(args *CommandLineArgs) error {
 	Env = Cfg.Section("").Key("app_mode").MustString("development")
 	InstanceName = Cfg.Section("").Key("instance_name").MustString("unknown_instance_name")
 	PluginsPath = makeAbsolute(Cfg.Section("paths").Key("plugins").String(), HomePath)
-	DatasourcesPath = makeAbsolute(Cfg.Section("paths").Key("datasources").String(), HomePath)
-
+	ProvisioningPath = makeAbsolute(Cfg.Section("paths").Key("provisioning").String(), HomePath)
 	server := Cfg.Section("server")
 	AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
 
@@ -670,6 +669,6 @@ func LogConfigurationInfo() {
 	logger.Info("Path Data", "path", DataPath)
 	logger.Info("Path Logs", "path", LogsPath)
 	logger.Info("Path Plugins", "path", PluginsPath)
-	logger.Info("Path Datasources", "path", DatasourcesPath)
+	logger.Info("Path Provisioning", "path", ProvisioningPath)
 	logger.Info("App mode " + Env)
 }

+ 10 - 8
pkg/social/github_oauth.go

@@ -58,12 +58,12 @@ func (s *SocialGithub) IsTeamMember(client *http.Client) bool {
 	return false
 }
 
-func (s *SocialGithub) IsOrganizationMember(client *http.Client) bool {
+func (s *SocialGithub) IsOrganizationMember(client *http.Client, organizationsUrl string) bool {
 	if len(s.allowedOrganizations) == 0 {
 		return true
 	}
 
-	organizations, err := s.FetchOrganizations(client)
+	organizations, err := s.FetchOrganizations(client, organizationsUrl)
 	if err != nil {
 		return false
 	}
@@ -167,12 +167,12 @@ func (s *SocialGithub) HasMoreRecords(headers http.Header) (string, bool) {
 
 }
 
-func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) {
+func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl string) ([]string, error) {
 	type Record struct {
 		Login string `json:"login"`
 	}
 
-	response, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/orgs"))
+	response, err := HttpGet(client, organizationsUrl)
 	if err != nil {
 		return nil, fmt.Errorf("Error getting organizations: %s", err)
 	}
@@ -193,10 +193,12 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error)
 }
 
 func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) {
+
 	var data struct {
-		Id    int    `json:"id"`
-		Login string `json:"login"`
-		Email string `json:"email"`
+		Id               int    `json:"id"`
+		Login            string `json:"login"`
+		Email            string `json:"email"`
+		OrganizationsUrl string `json:"organizations_url"`
 	}
 
 	response, err := HttpGet(client, s.apiUrl)
@@ -219,7 +221,7 @@ func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) {
 		return nil, ErrMissingTeamMembership
 	}
 
-	if !s.IsOrganizationMember(client) {
+	if !s.IsOrganizationMember(client, data.OrganizationsUrl) {
 		return nil, ErrMissingOrganizationMembership
 	}
 

+ 9 - 3
pkg/tsdb/cloudwatch/credentials.go

@@ -11,6 +11,7 @@ import (
 	"github.com/aws/aws-sdk-go/aws/credentials"
 	"github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
 	"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
+	"github.com/aws/aws-sdk-go/aws/defaults"
 	"github.com/aws/aws-sdk-go/aws/ec2metadata"
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/cloudwatch"
@@ -128,10 +129,10 @@ func remoteCredProvider(sess *session.Session) credentials.Provider {
 func ecsCredProvider(sess *session.Session, uri string) credentials.Provider {
 	const host = `169.254.170.2`
 
-	c := ec2metadata.New(sess)
+	d := defaults.Get()
 	return endpointcreds.NewProviderClient(
-		c.Client.Config,
-		c.Client.Handlers,
+		*d.Config,
+		d.Handlers,
 		fmt.Sprintf("http://%s%s", host, uri),
 		func(p *endpointcreds.Provider) { p.ExpiryWindow = 5 * time.Minute })
 }
@@ -141,6 +142,11 @@ func ec2RoleProvider(sess *session.Session) credentials.Provider {
 }
 
 func (e *CloudWatchExecutor) getDsInfo(region string) *DatasourceInfo {
+	defaultRegion := e.DataSource.JsonData.Get("defaultRegion").MustString()
+	if region == "default" {
+		region = defaultRegion
+	}
+
 	authType := e.DataSource.JsonData.Get("authType").MustString()
 	assumeRoleArn := e.DataSource.JsonData.Get("assumeRoleArn").MustString()
 	accessKey := ""

+ 1 - 1
pkg/tsdb/postgres/macros.go

@@ -89,7 +89,7 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string,
 		if err != nil {
 			return "", fmt.Errorf("error parsing interval %v", args[1])
 		}
-		return fmt.Sprintf("(extract(epoch from \"%s\")/%v)::bigint*%v", args[0], interval.Seconds(), interval.Seconds()), nil
+		return fmt.Sprintf("(extract(epoch from %s)/%v)::bigint*%v AS time", args[0], interval.Seconds(), interval.Seconds()), nil
 	case "__unixEpochFilter":
 		if len(args) == 0 {
 			return "", fmt.Errorf("missing time column argument for macro %v", name)

+ 1 - 1
pkg/tsdb/postgres/macros_test.go

@@ -45,7 +45,7 @@ func TestMacroEngine(t *testing.T) {
 			sql, err := engine.Interpolate(timeRange, "GROUP BY $__timeGroup(time_column,'5m')")
 			So(err, ShouldBeNil)
 
-			So(sql, ShouldEqual, "GROUP BY (extract(epoch from \"time_column\")/300)::bigint*300")
+			So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time")
 		})
 
 		Convey("interpolate __timeTo function", func() {

+ 2 - 2
public/app/core/components/EmptyListCTA/EmptyListCTA.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 
 export interface IProps {
     model: any;
@@ -17,7 +17,7 @@ class EmptyListCTA extends Component<IProps, any> {
             proTipTarget
         } = this.props.model;
         return (
-            <div className="empty-list-cta p-t-2 p-b-1">
+            <div className="empty-list-cta">
                 <div className="empty-list-cta__title">{title}</div>
                 <a href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success"><i className={buttonIcon} />{buttonTitle}</a>
                 <div className="empty-list-cta__pro-tip">

+ 1 - 1
public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap

@@ -2,7 +2,7 @@
 
 exports[`CollorPalette renders correctly 1`] = `
 <div
-  className="empty-list-cta p-t-2 p-b-1"
+  className="empty-list-cta"
 >
   <div
     className="empty-list-cta__title"

+ 0 - 2
public/app/core/components/dashboard_selector.ts

@@ -1,5 +1,3 @@
-///<reference path="../../headers/common.d.ts" />
-
 import coreModule from 'app/core/core_module';
 
 var template = `

+ 3 - 3
public/app/core/components/manage_dashboards/manage_dashboards.html

@@ -4,7 +4,7 @@
     <input type="text" class="gf-form-input max-width-30" placeholder="Find Dashboard by name" tabindex="1" give-focus="true" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.onQueryChange()" />
   </div>
   <div class="page-action-bar__spacer"></div>
-  <a class="btn btn-success" href="/dashboard/new">
+  <a class="btn btn-success" href="/dashboard/new?folderId={{ctrl.folderId}}">
     <i class="fa fa-plus"></i>
     Dashboard
   </a>
@@ -88,11 +88,11 @@
   <empty-list-cta model="{
     title: 'This folder doesn\'t have any dashboards yet',
     buttonIcon: 'gicon gicon-dashboard-new',
-    buttonLink: '/dashboard/new',
+    buttonLink: '/dashboard/new?folderId={{ctrl.folderId}}',
     buttonTitle: 'Create Dashboard',
     proTip: 'You can bulk move dashboards into this folder from the main dashboard list.',
     proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
     proTipLinkTitle: 'Learn more',
     proTipTarget: '_blank'
   }" />
-</div>
+</div>

+ 9 - 1
public/app/core/components/search/search.html

@@ -55,7 +55,15 @@
       </div>
 
       <div class="search-filter-box">
-        <a class="search-button-row-explore-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
+        <a href="dashboard/new" class="search-filter-box-link">
+          <i class="gicon gicon-dashboard-new"></i>
+          Dashboard
+        </a>
+        <a href="dashboards/folder/new" class="search-filter-box-link">
+          <i class="gicon gicon-folder-new"></i>
+          Folder
+        </a>
+        <a class="search-filter-box-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
           <img src="public/img/icn-dashboard-tiny.svg" width="20" /> Find  dashboards on Grafana.com
         </a>
       </div>

+ 5 - 3
public/app/core/components/search/search_results.html

@@ -13,9 +13,10 @@
     <div ng-show="ctrl.editable && section.id > 0" ng-click="ctrl.navigateToFolder(section, $event)">
         <i class="fa fa-cog search-section__header__toggle"></i>&nbsp;
     </div>
-    <i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
-    <i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
+		<i class="fa fa-angle-down search-section__header__toggle" ng-show="section.expanded"></i>
+    <i class="fa fa-angle-right search-section__header__toggle" ng-hide="section.expanded"></i>
   </a>
+
   <div class="search-section__header" ng-show="section.hideHeader"></div>
 
   <div ng-if="section.expanded">
@@ -44,4 +45,5 @@
       </span>
     </a>
   </div>
-</div>
+</div>
+

+ 10 - 10
public/app/core/components/user_group_picker.ts → public/app/core/components/team_picker.ts

@@ -10,9 +10,9 @@ const template = `
   </gf-form-dropdown>
 </div>
 `;
-export class UserGroupPickerCtrl {
+export class TeamPickerCtrl {
   group: any;
-  userGroupPicked: any;
+  teamPicked: any;
   debouncedSearchGroups: any;
 
   /** @ngInject */
@@ -26,34 +26,34 @@ export class UserGroupPickerCtrl {
   }
 
   searchGroups(query: string) {
-    return Promise.resolve(this.backendSrv.get('/api/user-groups/search?perpage=10&page=1&query=' + query).then(result => {
-      return _.map(result.userGroups, ug => {
+    return Promise.resolve(this.backendSrv.get('/api/teams/search?perpage=10&page=1&query=' + query).then(result => {
+      return _.map(result.teams, ug => {
         return {text: ug.name, value: ug};
       });
     }));
   }
 
   onChange(option) {
-    this.userGroupPicked({$group: option.value});
+    this.teamPicked({$group: option.value});
   }
 }
 
-export function userGroupPicker() {
+export function teamPicker() {
   return {
     restrict: 'E',
     template: template,
-    controller: UserGroupPickerCtrl,
+    controller: TeamPickerCtrl,
     bindToController: true,
     controllerAs: 'ctrl',
     scope: {
-      userGroupPicked: '&',
+      teamPicked: '&',
     },
     link: function(scope, elem, attrs, ctrl) {
-      scope.$on("user-group-picker-reset", () => {
+      scope.$on("team-picker-reset", () => {
         ctrl.reset();
       });
     }
   };
 }
 
-coreModule.directive('userGroupPicker', userGroupPicker);
+coreModule.directive('teamPicker', teamPicker);

+ 2 - 2
public/app/core/core.ts

@@ -47,7 +47,7 @@ import {helpModal} from './components/help/help';
 import {JsonExplorer} from './components/json_explorer/json_explorer';
 import {NavModelSrv, NavModel} from './nav_model_srv';
 import {userPicker} from './components/user_picker';
-import {userGroupPicker} from './components/user_group_picker';
+import {teamPicker} from './components/team_picker';
 import {geminiScrollbar} from './components/scroll/scroll';
 import {gfPageDirective} from './components/gf_page';
 import {orgSwitcher} from './components/org_switcher';
@@ -85,7 +85,7 @@ export {
   NavModelSrv,
   NavModel,
   userPicker,
-  userGroupPicker,
+  teamPicker,
   geminiScrollbar,
   gfPageDirective,
   orgSwitcher,

+ 12 - 13
public/app/core/directives/dash_class.js

@@ -18,22 +18,21 @@ function (_, $, coreModule) {
           elem.toggleClass('panel-in-fullscreen', false);
         });
 
-        var lastHideControlsVal;
-        $scope.$watch('ctrl.dashboard.hideControls', function() {
-          if (!$scope.dashboard) {
-            return;
-          }
-
-          var hideControls = $scope.dashboard.hideControls;
-          if (lastHideControlsVal !== hideControls) {
-            elem.toggleClass('hide-controls', hideControls);
-            lastHideControlsVal = hideControls;
-          }
-        });
-
         $scope.$watch('ctrl.playlistSrv.isPlaying', function(newValue) {
           elem.toggleClass('playlist-active', newValue === true);
         });
+
+        $scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
+          if (newValue) {
+            elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));
+            setTimeout(function() {
+              elem.toggleClass('dashboard-page--settings-open', _.isString(newValue));
+            }, 10);
+          } else {
+            elem.removeClass('dashboard-page--settings-opening');
+            elem.removeClass('dashboard-page--settings-open');
+          }
+        });
       }
     };
   });

+ 0 - 1
public/app/core/directives/dash_edit_link.js

@@ -13,7 +13,6 @@ function ($, angular, coreModule, _) {
     'templating':  { src: 'public/app/features/templating/partials/editor.html'},
     'history':     { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
     'timepicker':  { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
-    'add-panel':    { html: '<add-panel></add-panel>' },
     'import':      { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
     'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
     'new-folder':  {

+ 3 - 2
public/app/core/routes/dashboard_loaders.ts

@@ -31,7 +31,7 @@ export class LoadDashboardCtrl {
 export class NewDashboardCtrl {
 
   /** @ngInject */
-  constructor($scope) {
+  constructor($scope, $routeParams) {
     $scope.initDashboard({
       meta: { canStar: false, canShare: false, isNew: true },
       dashboard: {
@@ -42,7 +42,8 @@ export class NewDashboardCtrl {
             gridPos: {x: 0, y: 0, w: 12, h: 9},
             title: 'Panel Title',
           }
-        ]
+        ],
+        folderId: Number($routeParams.folderId)
       },
     }, $scope);
   }

+ 11 - 6
public/app/core/routes/routes.ts

@@ -104,20 +104,25 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     controllerAs: 'ctrl',
     resolve: loadOrgBundle,
   })
+  .when('/org/users/new', {
+    templateUrl: 'public/app/features/org/partials/invite.html',
+    controller : 'UserInviteCtrl',
+    resolve: loadOrgBundle,
+  })
   .when('/org/apikeys', {
     templateUrl: 'public/app/features/org/partials/orgApiKeys.html',
     controller : 'OrgApiKeysCtrl',
     resolve: loadOrgBundle,
   })
-  .when('/org/user-groups', {
-    templateUrl: 'public/app/features/org/partials/user_groups.html',
-    controller : 'UserGroupsCtrl',
+  .when('/org/teams', {
+    templateUrl: 'public/app/features/org/partials/teams.html',
+    controller : 'TeamsCtrl',
     controllerAs: 'ctrl',
     resolve: loadOrgBundle,
   })
-  .when('/org/user-groups/edit/:id', {
-    templateUrl: 'public/app/features/org/partials/user_group_details.html',
-    controller : 'UserGroupDetailsCtrl',
+  .when('/org/teams/edit/:id', {
+    templateUrl: 'public/app/features/org/partials/team_details.html',
+    controller : 'TeamDetailsCtrl',
     controllerAs: 'ctrl',
     resolve: loadOrgBundle,
   })

+ 10 - 8
public/app/core/services/keybindingSrv.ts

@@ -72,8 +72,8 @@ export class KeybindingSrv {
     }, 'keydown');
   }
 
-  showDashEditView(view) {
-    var search = _.extend(this.$location.search(), {editview: view});
+  showDashEditView() {
+    var search = _.extend(this.$location.search(), {editview: 'settings'});
     this.$location.search(search);
   }
 
@@ -84,10 +84,6 @@ export class KeybindingSrv {
       scope.broadcastRefresh();
     });
 
-    this.bind('mod+h', () => {
-      dashboard.hideControls = !dashboard.hideControls;
-    });
-
     this.bind('mod+s', e => {
       scope.appEvent('save-dashboard');
     });
@@ -197,7 +193,7 @@ export class KeybindingSrv {
     });
 
     this.bind('d s', () => {
-      this.showDashEditView('settings');
+      this.showDashEditView();
     });
 
     this.bind('d k', () => {
@@ -215,8 +211,14 @@ export class KeybindingSrv {
       }
 
       scope.appEvent('hide-modal');
-      scope.appEvent('hide-dash-editor');
       scope.appEvent('panel-change-view', {fullscreen: false, edit: false});
+
+      // close settings view
+      var search = this.$location.search();
+      if (search.editview) {
+        delete search.editview;
+        this.$location.search(search);
+      }
     });
   }
 }

+ 7 - 0
public/app/core/services/popover_srv.ts

@@ -68,6 +68,13 @@ function popoverSrv($compile, $rootScope, $timeout) {
       openDrop = drop;
       openDrop.open();
     }, 100);
+
+    // return close function
+    return function() {
+      if (drop) {
+        drop.close();
+      }
+    };
   };
 }
 

+ 1 - 1
public/app/features/annotations/annotation_tooltip.ts

@@ -4,7 +4,7 @@ import coreModule from 'app/core/core_module';
 import alertDef from '../alerting/alert_def';
 
 /** @ngInject **/
-export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, popoverSrv, $compile) {
+export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, $compile) {
 
   function sanitizeString(str) {
     try {

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.