diff --git a/.gitignore b/.gitignore index f743dcdc015..12e7bed3f46 100644 --- a/.gitignore +++ b/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b4adfcdda2..f6512314d65 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/README.md b/README.md index aefc0c0802b..5358cd3f3d5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides. ## Run from master -If you want to build a package yourself, or contribute. Here is a guide for how to do that. You can always find +If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies @@ -97,7 +97,7 @@ Writing & watching frontend tests (we have two test runners) ## Contribute -If you have any idea for an improvement or found a bug do not hesitate to open an issue. +If you have any idea for an improvement or found a bug, do not hesitate to open an issue. And if you have time clone this repo and submit a pull request and help me make Grafana the kickass metrics & devops dashboard we all dream about! diff --git a/ROADMAP.md b/ROADMAP.md index 4273d8df6a9..479c1933bc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ But it will give you an idea of our current vision and plan. ### Short term (1-4 months) - Release Grafana v5 - - User groups + - Teams - Dashboard folders - Dashboard & folder permissions (assigned to users or groups) - New Dashboard layout engine diff --git a/conf/defaults.ini b/conf/defaults.ini index a145d57482b..218c91608cc 100644 --- a/conf/defaults.ini +++ b/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 diff --git a/conf/provisioning/dashboards/sample.yaml b/conf/provisioning/dashboards/sample.yaml new file mode 100644 index 00000000000..40992d1461e --- /dev/null +++ b/conf/provisioning/dashboards/sample.yaml @@ -0,0 +1,6 @@ +# - name: 'default' +# org_id: 1 +# folder: '' +# type: file +# options: +# folder: /var/lib/grafana/dashboards \ No newline at end of file diff --git a/conf/datasources/datasources.yaml b/conf/provisioning/datasources/sample.yaml similarity index 83% rename from conf/datasources/datasources.yaml rename to conf/provisioning/datasources/sample.yaml index d8ddc9c6bed..1bb9cb53b45 100644 --- a/conf/datasources/datasources.yaml +++ b/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: # # name of the datasource. Required # - name: Graphite # # datasource type. Required @@ -33,7 +33,7 @@ datasources: # # mark as default datasource. Max one per org # is_default: # # fields that will be converted to json and stored in json_data -# json_data: +# json_data: # graphiteVersion: "1.1" # tlsAuth: true # tlsAuthWithCACert: true diff --git a/conf/sample.ini b/conf/sample.ini index 233a97deef8..7107f8354d6 100644 --- a/conf/sample.ini +++ b/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 diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 0745794496f..3dd5c5fd1d3 100644 --- a/docs/sources/administration/provisioning.md +++ b/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. \ No newline at end of file diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index bdf661dc4fc..648957ed96e 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/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`. diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index e9d65b8f327..7d52df2fd3e 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/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 diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index d454455a26b..1333c8c191d 100644 --- a/docs/sources/installation/configuration.md +++ b/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. diff --git a/package.json b/package.json index 59608e80403..5516ad97fd7 100644 --- a/package.json +++ b/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", diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index 8e25a0e4124..351c966a8e6 100755 --- a/packaging/deb/control/postinst +++ b/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 diff --git a/packaging/deb/default/grafana-server b/packaging/deb/default/grafana-server index eaa75830d44..eb77e62d774 100644 --- a/packaging/deb/default/grafana-server +++ b/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 diff --git a/packaging/deb/init.d/grafana-server b/packaging/deb/init.d/grafana-server index 85b0e412d35..567da94f881 100755 --- a/packaging/deb/init.d/grafana-server +++ b/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 diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index cb7b87932d1..acd2a360a93 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/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 diff --git a/packaging/mac/bin/grafana b/packaging/mac/bin/grafana index fb33079079e..74f4b00662b 100755 --- a/packaging/mac/bin/grafana +++ b/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) diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index 0bfca949e7f..e75850f258e 100755 --- a/packaging/rpm/control/postinst +++ b/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 diff --git a/packaging/rpm/init.d/grafana-server b/packaging/rpm/init.d/grafana-server index dc63e1ef4c6..cefe212116c 100755 --- a/packaging/rpm/init.d/grafana-server +++ b/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 diff --git a/packaging/rpm/sysconfig/grafana-server b/packaging/rpm/sysconfig/grafana-server index eaa75830d44..eb77e62d774 100644 --- a/packaging/rpm/sysconfig/grafana-server +++ b/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 diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index b23e5196e17..f228c8d8b14 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/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 diff --git a/pkg/api/api.go b/pkg/api/api.go index b707dc17e21..a37f200f9d1 100644 --- a/pkg/api/api.go +++ b/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. diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index fdf93d06b5d..6824e330f00 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -25,6 +25,8 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" + + gocache "github.com/patrickmn/go-cache" ) var gravatarSource string @@ -92,7 +94,7 @@ func (this *Avatar) Update() (err error) { type CacheServer struct { notFound *Avatar - cache map[string]*Avatar + cache *gocache.Cache } func (this *CacheServer) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) { @@ -110,7 +112,9 @@ func (this *CacheServer) Handler(ctx *macaron.Context) { var avatar *Avatar - if avatar, _ = this.cache[hash]; avatar == nil { + if obj, exist := this.cache.Get(hash); exist { + avatar = obj.(*Avatar) + } else { avatar = New(hash) } @@ -124,7 +128,7 @@ func (this *CacheServer) Handler(ctx *macaron.Context) { if avatar.notFound { avatar = this.notFound } else { - this.cache[hash] = avatar + this.cache.Add(hash, avatar, gocache.DefaultExpiration) } ctx.Resp.Header().Add("Content-Type", "image/jpeg") @@ -146,7 +150,7 @@ func NewCacheServer() *CacheServer { return &CacheServer{ notFound: newNotFound(), - cache: make(map[string]*Avatar), + cache: gocache.New(time.Hour, time.Hour*2), } } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index c35f0ca64e8..8a1f0f85c28 100644 --- a/pkg/api/dashboard.go +++ b/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 { diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go index c6847963d34..88cc74b9d1c 100644 --- a/pkg/api/dashboard_acl.go +++ b/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(), diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index 162bac0aba8..e22e625dcf9 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/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) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index cd27639c4e4..1ddb32b4adc 100644 --- a/pkg/api/dashboard_test.go +++ b/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) diff --git a/pkg/api/dtos/acl.go b/pkg/api/dtos/acl.go index 41f560e794b..6c74e68ce0d 100644 --- a/pkg/api/dtos/acl.go +++ b/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"` } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 89456d20d8c..0366b9aedad 100644 --- a/pkg/api/http_server.go +++ b/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 } diff --git a/pkg/api/index.go b/pkg/api/index.go index e179828d2f7..862e9af0519 100644 --- a/pkg/api/index.go +++ b/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", diff --git a/pkg/api/render.go b/pkg/api/render.go index 6d1b49e4462..be85da35853 100644 --- a/pkg/api/render.go +++ b/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) diff --git a/pkg/api/team.go b/pkg/api/team.go new file mode 100644 index 00000000000..31e465d3232 --- /dev/null +++ b/pkg/api/team.go @@ -0,0 +1,92 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// POST /api/teams +func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response { + cmd.OrgId = c.OrgId + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamNameTaken { + return ApiError(409, "Team name taken", err) + } + return ApiError(500, "Failed to create Team", err) + } + + return Json(200, &util.DynMap{ + "teamId": cmd.Result.Id, + "message": "Team created", + }) +} + +// PUT /api/teams/:teamId +func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { + cmd.Id = c.ParamsInt64(":teamId") + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamNameTaken { + return ApiError(400, "Team name taken", err) + } + return ApiError(500, "Failed to update Team", err) + } + + return ApiSuccess("Team updated") +} + +// DELETE /api/teams/:teamId +func DeleteTeamById(c *middleware.Context) Response { + if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil { + if err == m.ErrTeamNotFound { + return ApiError(404, "Failed to delete Team. ID not found", nil) + } + return ApiError(500, "Failed to update Team", err) + } + return ApiSuccess("Team deleted") +} + +// GET /api/teams/search +func SearchTeams(c *middleware.Context) Response { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 1000 + } + page := c.QueryInt("page") + if page < 1 { + page = 1 + } + + query := m.SearchTeamsQuery{ + Query: c.Query("query"), + Name: c.Query("name"), + Page: page, + Limit: perPage, + OrgId: c.OrgId, + } + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to search Teams", err) + } + + query.Result.Page = page + query.Result.PerPage = perPage + + return Json(200, query.Result) +} + +// GET /api/teams/:teamId +func GetTeamById(c *middleware.Context) Response { + query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")} + + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrTeamNotFound { + return ApiError(404, "Team not found", err) + } + + return ApiError(500, "Failed to get Team", err) + } + + return Json(200, &query.Result) +} diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go new file mode 100644 index 00000000000..0999c9573a5 --- /dev/null +++ b/pkg/api/team_members.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// GET /api/teams/:teamId/members +func GetTeamMembers(c *middleware.Context) Response { + query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to get Team Members", err) + } + + return Json(200, query.Result) +} + +// POST /api/teams/:teamId/members +func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response { + cmd.TeamId = c.ParamsInt64(":teamId") + cmd.OrgId = c.OrgId + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamMemberAlreadyAdded { + return ApiError(400, "User is already added to this team", err) + } + return ApiError(500, "Failed to add Member to Team", err) + } + + return Json(200, &util.DynMap{ + "message": "Member added to Team", + }) +} + +// DELETE /api/teams/:teamId/members/:userId +func RemoveTeamMember(c *middleware.Context) Response { + if err := bus.Dispatch(&m.RemoveTeamMemberCommand{TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { + return ApiError(500, "Failed to remove Member from Team", err) + } + return ApiSuccess("Team Member removed") +} diff --git a/pkg/api/user_group_test.go b/pkg/api/team_test.go similarity index 61% rename from pkg/api/user_group_test.go rename to pkg/api/team_test.go index 730461ac8e8..535d24ff553 100644 --- a/pkg/api/user_group_test.go +++ b/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) diff --git a/pkg/api/user_group.go b/pkg/api/user_group.go deleted file mode 100644 index 8371881aecb..00000000000 --- a/pkg/api/user_group.go +++ /dev/null @@ -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) -} diff --git a/pkg/api/user_group_members.go b/pkg/api/user_group_members.go deleted file mode 100644 index 09f3def59bd..00000000000 --- a/pkg/api/user_group_members.go +++ /dev/null @@ -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") -} diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 183e4b047cd..ab0e12f2d9f 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/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 } } diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index f5c6b0d1cee..b84c3d4e3d6 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/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 == "" { diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index cc087106752..25d77557342 100644 --- a/pkg/components/renderer/renderer.go +++ b/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() diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index 9b65ec8eb33..fa7ad00de7f 100644 --- a/pkg/models/dashboard_acl.go +++ b/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 diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 1d84d8250a6..51df2e55a57 100644 --- a/pkg/models/dashboards.go +++ b/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 } diff --git a/pkg/models/server.go b/pkg/models/server.go deleted file mode 100644 index 4d683835256..00000000000 --- a/pkg/models/server.go +++ /dev/null @@ -1,6 +0,0 @@ -package models - -type GrafanaServer interface { - Start() - Shutdown(code int, reason string) -} diff --git a/pkg/models/team.go b/pkg/models/team.go new file mode 100644 index 00000000000..a8f53e81a1e --- /dev/null +++ b/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"` +} diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go new file mode 100644 index 00000000000..71e5cd4ba12 --- /dev/null +++ b/pkg/models/team_member.go @@ -0,0 +1,55 @@ +package models + +import ( + "errors" + "time" +) + +// Typed errors +var ( + ErrTeamMemberAlreadyAdded = errors.New("User is already added to this team") +) + +// TeamMember model +type TeamMember struct { + Id int64 + OrgId int64 + TeamId int64 + UserId int64 + + Created time.Time + Updated time.Time +} + +// --------------------- +// COMMANDS + +type AddTeamMemberCommand struct { + UserId int64 `json:"userId" binding:"Required"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` +} + +type RemoveTeamMemberCommand struct { + UserId int64 + TeamId int64 +} + +// ---------------------- +// QUERIES + +type GetTeamMembersQuery struct { + TeamId int64 + Result []*TeamMemberDTO +} + +// ---------------------- +// Projections and DTOs + +type TeamMemberDTO struct { + OrgId int64 `json:"orgId"` + TeamId int64 `json:"teamId"` + UserId int64 `json:"userId"` + Email string `json:"email"` + Login string `json:"login"` +} diff --git a/pkg/models/user_group.go b/pkg/models/user_group.go deleted file mode 100644 index e5e361526d4..00000000000 --- a/pkg/models/user_group.go +++ /dev/null @@ -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"` -} diff --git a/pkg/models/user_group_member.go b/pkg/models/user_group_member.go deleted file mode 100644 index 23ddd84601b..00000000000 --- a/pkg/models/user_group_member.go +++ /dev/null @@ -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"` -} diff --git a/pkg/services/dashboards/dashboards.go b/pkg/services/dashboards/dashboards.go new file mode 100644 index 00000000000..24e9f240fd8 --- /dev/null +++ b/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 +} diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index a05213e87c8..72fe00a4ceb 100644 --- a/pkg/services/guardian/guardian.go +++ b/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 diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go new file mode 100644 index 00000000000..a602ca71df3 --- /dev/null +++ b/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 +} diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go new file mode 100644 index 00000000000..56c5a5fcf3d --- /dev/null +++ b/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) + + }) + }) +} diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go new file mode 100644 index 00000000000..1ee0f78497d --- /dev/null +++ b/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 +} diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go new file mode 100644 index 00000000000..42f232bca82 --- /dev/null +++ b/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 +} diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go new file mode 100644 index 00000000000..e91f278322a --- /dev/null +++ b/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 +} diff --git a/pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml b/pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml new file mode 100644 index 00000000000..e40612af508 --- /dev/null +++ b/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 diff --git a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml new file mode 100644 index 00000000000..a7c4a812092 --- /dev/null +++ b/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 diff --git a/pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/empty-json.json b/pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/empty-json.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/invalid.json b/pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/invalid.json new file mode 100644 index 00000000000..0c5e34c2da7 --- /dev/null +++ b/pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/invalid.json @@ -0,0 +1,6 @@ +[] +{ + "title": "Grafana", + + } + \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json new file mode 100644 index 00000000000..5b6765a4ed6 --- /dev/null +++ b/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": "
\n \n
", + "style": {}, + "title": "Welcome to" + } + ] + }, + { + "title": "Welcome to Grafana", + "height": "210px", + "collapse": false, + "editable": true, + "panels": [ + { + "id": 2, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n \n
\n
\n \n
\n
", + "style": {}, + "title": "Documentation Links" + }, + { + "id": 3, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n
    \n
  • Ctrl+S saves the current dashboard
  • \n
  • Ctrl+F Opens the dashboard finder
  • \n
  • Ctrl+H Hide/show row controls
  • \n
  • Click and drag graph title to move panel
  • \n
  • Hit Escape to exit graph when in fullscreen or edit mode
  • \n
  • Click the colored icon in the legend to change series color
  • \n
  • Ctrl or Shift + Click legend name to hide other series
  • \n
\n
\n
\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 + } + \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json new file mode 100644 index 00000000000..5b6765a4ed6 --- /dev/null +++ b/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": "
\n \n
", + "style": {}, + "title": "Welcome to" + } + ] + }, + { + "title": "Welcome to Grafana", + "height": "210px", + "collapse": false, + "editable": true, + "panels": [ + { + "id": 2, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n \n
\n
\n \n
\n
", + "style": {}, + "title": "Documentation Links" + }, + { + "id": 3, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n
    \n
  • Ctrl+S saves the current dashboard
  • \n
  • Ctrl+F Opens the dashboard finder
  • \n
  • Ctrl+H Hide/show row controls
  • \n
  • Click and drag graph title to move panel
  • \n
  • Hit Escape to exit graph when in fullscreen or edit mode
  • \n
  • Click the colored icon in the legend to change series color
  • \n
  • Ctrl or Shift + Click legend name to hide other series
  • \n
\n
\n
\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 + } + \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/test-dashboards/one-dashboard/dashboard1.json b/pkg/services/provisioning/dashboards/test-dashboards/one-dashboard/dashboard1.json new file mode 100644 index 00000000000..5b6765a4ed6 --- /dev/null +++ b/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": "
\n \n
", + "style": {}, + "title": "Welcome to" + } + ] + }, + { + "title": "Welcome to Grafana", + "height": "210px", + "collapse": false, + "editable": true, + "panels": [ + { + "id": 2, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n \n
\n
\n \n
\n
", + "style": {}, + "title": "Documentation Links" + }, + { + "id": 3, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n
    \n
  • Ctrl+S saves the current dashboard
  • \n
  • Ctrl+F Opens the dashboard finder
  • \n
  • Ctrl+H Hide/show row controls
  • \n
  • Click and drag graph title to move panel
  • \n
  • Hit Escape to exit graph when in fullscreen or edit mode
  • \n
  • Click the colored icon in the legend to change series color
  • \n
  • Ctrl or Shift + Click legend name to hide other series
  • \n
\n
\n
\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 + } + \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go new file mode 100644 index 00000000000..002a56b5f3c --- /dev/null +++ b/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 +} diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 325dbbbd757..ce631c565d4 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/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 } diff --git a/pkg/services/provisioning/datasources/test-configs/broken-yaml/commented.yaml b/pkg/services/provisioning/datasources/test-configs/broken-yaml/commented.yaml new file mode 100644 index 00000000000..1bb9cb53b45 --- /dev/null +++ b/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: +# # name of the datasource. Required +# - name: Graphite +# # datasource type. Required +# type: graphite +# # access mode. direct or proxy. Required +# access: proxy +# # org id. will default to org_id 1 if not specified +# org_id: 1 +# # url +# url: http://localhost:8080 +# # database password, if used +# password: +# # database user, if used +# user: +# # database name, if used +# database: +# # enable/disable basic auth +# basic_auth: +# # basic auth username +# basic_auth_user: +# # basic auth password +# basic_auth_password: +# # enable/disable with credentials headers +# with_credentials: +# # mark as default datasource. Max one per org +# is_default: +# # fields that will be converted to json and stored in json_data +# json_data: +# graphiteVersion: "1.1" +# tlsAuth: true +# tlsAuthWithCACert: true +# # json object of data that will be encrypted. +# secure_json_data: +# tlsCACert: "..." +# tlsClientCert: "..." +# tlsClientKey: "..." +# version: 1 +# # allow users to edit datasources from the UI. +# editable: false + diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 1bea60f03e4..b41ec37b797 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/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) -func StartUp(datasourcePath string) error { - return datasources.Provision(datasourcePath) + 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 makeAbsolute(path string, root string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(root, path) } diff --git a/pkg/services/search/handlers_test.go b/pkg/services/search/handlers_test.go index 92a96dfd102..fc223b2ef4b 100644 --- a/pkg/services/search/handlers_test.go +++ b/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"}}, diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 8ab0d50b85f..af27dad59de 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/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) } diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 8d3a2455f1f..3ab0361d175 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/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 diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/sqlstore/dashboard_acl_test.go index 55f4b24808b..bb6363883d6 100644 --- a/pkg/services/sqlstore/dashboard_acl_test.go +++ b/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) }) }) diff --git a/pkg/services/sqlstore/migrations/dashboard_acl.go b/pkg/services/sqlstore/migrations/dashboard_acl.go index af85e48af0d..c968f5f072a 100644 --- a/pkg/services/sqlstore/migrations/dashboard_acl.go +++ b/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 diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 3cd4f4399fb..8e9268779ef 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/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) } diff --git a/pkg/services/sqlstore/migrations/user_group_mig.go b/pkg/services/sqlstore/migrations/team_mig.go similarity index 50% rename from pkg/services/sqlstore/migrations/user_group_mig.go rename to pkg/services/sqlstore/migrations/team_mig.go index 589def0c504..cc479097f9b 100644 --- a/pkg/services/sqlstore/migrations/user_group_mig.go +++ b/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])) } diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 2acb623bd34..2c2a51fd362 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/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 { diff --git a/pkg/services/sqlstore/search_builder.go b/pkg/services/sqlstore/search_builder.go index 99bde21d171..6a5e8e60b54 100644 --- a/pkg/services/sqlstore/search_builder.go +++ b/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 diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index f37499bd60f..2655bf9c22e 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/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 } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go new file mode 100644 index 00000000000..2bc04bad0c6 --- /dev/null +++ b/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 +} diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go new file mode 100644 index 00000000000..4a099db14ff --- /dev/null +++ b/pkg/services/sqlstore/team_test.go @@ -0,0 +1,114 @@ +package sqlstore + +import ( + "fmt" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" +) + +func TestTeamCommandsAndQueries(t *testing.T) { + + Convey("Testing Team commands & queries", t, func() { + InitTestDB(t) + + Convey("Given saved users and two teams", func() { + var userIds []int64 + for i := 0; i < 5; i++ { + userCmd := &m.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("loginuser", i), + } + err := CreateUser(userCmd) + So(err, ShouldBeNil) + userIds = append(userIds, userCmd.Result.Id) + } + + group1 := m.CreateTeamCommand{Name: "group1 name"} + group2 := m.CreateTeamCommand{Name: "group2 name"} + + err := CreateTeam(&group1) + So(err, ShouldBeNil) + err = CreateTeam(&group2) + So(err, ShouldBeNil) + + Convey("Should be able to create teams and add users", func() { + query := &m.SearchTeamsQuery{Name: "group1 name", Page: 1, Limit: 10} + err = SearchTeams(query) + So(err, ShouldBeNil) + So(query.Page, ShouldEqual, 1) + + team1 := query.Result.Teams[0] + So(team1.Name, ShouldEqual, "group1 name") + + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]}) + So(err, ShouldBeNil) + + q1 := &m.GetTeamMembersQuery{TeamId: team1.Id} + err = GetTeamMembers(q1) + So(err, ShouldBeNil) + So(q1.Result[0].TeamId, ShouldEqual, team1.Id) + So(q1.Result[0].Login, ShouldEqual, "loginuser0") + }) + + Convey("Should be able to search for teams", func() { + query := &m.SearchTeamsQuery{Query: "group", Page: 1} + err = SearchTeams(query) + So(err, ShouldBeNil) + So(len(query.Result.Teams), ShouldEqual, 2) + So(query.Result.TotalCount, ShouldEqual, 2) + + query2 := &m.SearchTeamsQuery{Query: ""} + err = SearchTeams(query2) + So(err, ShouldBeNil) + So(len(query2.Result.Teams), ShouldEqual, 2) + }) + + Convey("Should be able to return all teams a user is member of", func() { + groupId := group2.Result.Id + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[0]}) + + query := &m.GetTeamsByUserQuery{UserId: userIds[0]} + err = GetTeamsByUser(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Name, ShouldEqual, "group2 name") + }) + + Convey("Should be able to remove users from a group", func() { + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{TeamId: group1.Result.Id, UserId: userIds[0]}) + So(err, ShouldBeNil) + + q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id} + err = GetTeamMembers(q1) + So(err, ShouldBeNil) + So(len(q1.Result), ShouldEqual, 0) + }) + + Convey("Should be able to remove a group with users and permissions", func() { + groupId := group2.Result.Id + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[1]}) + So(err, ShouldBeNil) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[2]}) + So(err, ShouldBeNil) + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, TeamId: groupId}) + + err = DeleteTeam(&m.DeleteTeamCommand{Id: groupId}) + So(err, ShouldBeNil) + + query := &m.GetTeamByIdQuery{Id: groupId} + err = GetTeamById(query) + So(err, ShouldEqual, m.ErrTeamNotFound) + + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1} + err = GetDashboardAclInfoList(permQuery) + So(err, ShouldBeNil) + + So(len(permQuery.Result), ShouldEqual, 0) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 669f655a159..73ea07f031f 100644 --- a/pkg/services/sqlstore/user.go +++ b/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 { diff --git a/pkg/services/sqlstore/user_group.go b/pkg/services/sqlstore/user_group.go deleted file mode 100644 index c90141310ab..00000000000 --- a/pkg/services/sqlstore/user_group.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/sqlstore/user_group_test.go b/pkg/services/sqlstore/user_group_test.go deleted file mode 100644 index 76056f98837..00000000000 --- a/pkg/services/sqlstore/user_group_test.go +++ /dev/null @@ -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) - }) - }) - }) -} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 2caf7366727..ef4a7c42e86 100644 --- a/pkg/setting/setting.go +++ b/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) } diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index c2a109a43e8..7e348e2363a 100644 --- a/pkg/social/github_oauth.go +++ b/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 } diff --git a/pkg/tsdb/cloudwatch/credentials.go b/pkg/tsdb/cloudwatch/credentials.go index 784f3b729ac..06848323fbb 100644 --- a/pkg/tsdb/cloudwatch/credentials.go +++ b/pkg/tsdb/cloudwatch/credentials.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go/aws/credentials/endpointcreds" + "github.com/aws/aws-sdk-go/aws/defaults" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatch" @@ -128,10 +129,10 @@ func remoteCredProvider(sess *session.Session) credentials.Provider { func ecsCredProvider(sess *session.Session, uri string) credentials.Provider { const host = `169.254.170.2` - c := ec2metadata.New(sess) + d := defaults.Get() return endpointcreds.NewProviderClient( - c.Client.Config, - c.Client.Handlers, + *d.Config, + d.Handlers, fmt.Sprintf("http://%s%s", host, uri), func(p *endpointcreds.Provider) { p.ExpiryWindow = 5 * time.Minute }) } @@ -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 := "" diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 288787589ce..086eb96655f 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/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) diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go index ff268805259..ebc5191d46e 100644 --- a/pkg/tsdb/postgres/macros_test.go +++ b/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() { diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx index a5a78a63bf1..1583303dfa1 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx +++ b/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 { proTipTarget } = this.props.model; return ( -
+
{title}
{buttonTitle}
diff --git a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap index 0598d7ecd4e..0da3d94aaa8 100644 --- a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap +++ b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap @@ -2,7 +2,7 @@ exports[`CollorPalette renders correctly 1`] = `
- import coreModule from 'app/core/core_module'; var template = ` diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.html b/public/app/core/components/manage_dashboards/manage_dashboards.html index 957bca0262c..a19a63a550b 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.html +++ b/public/app/core/components/manage_dashboards/manage_dashboards.html @@ -4,7 +4,7 @@
- + Dashboard @@ -88,11 +88,11 @@ -
\ No newline at end of file +
diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index f9b953efcee..03d2a66108c 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -55,7 +55,15 @@
diff --git a/public/app/core/components/search/search_results.html b/public/app/core/components/search/search_results.html index ad0aa3e6b71..324654b9815 100644 --- a/public/app/core/components/search/search_results.html +++ b/public/app/core/components/search/search_results.html @@ -13,9 +13,10 @@
 
- - + + +
@@ -44,4 +45,5 @@
-
\ No newline at end of file + + diff --git a/public/app/core/components/user_group_picker.ts b/public/app/core/components/team_picker.ts similarity index 67% rename from public/app/core/components/user_group_picker.ts rename to public/app/core/components/team_picker.ts index f9c91a7f0e6..2bf6b3c83b5 100644 --- a/public/app/core/components/user_group_picker.ts +++ b/public/app/core/components/team_picker.ts @@ -10,9 +10,9 @@ const template = ` `; -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); diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 5a9b4265f8c..f43f4df3110 100644 --- a/public/app/core/core.ts +++ b/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, diff --git a/public/app/core/directives/dash_class.js b/public/app/core/directives/dash_class.js index d8ad335de5c..9df53bdbd48 100644 --- a/public/app/core/directives/dash_class.js +++ b/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'); + } + }); } }; }); diff --git a/public/app/core/directives/dash_edit_link.js b/public/app/core/directives/dash_edit_link.js index 02c7d55b99e..f0dc6f59a6a 100644 --- a/public/app/core/directives/dash_edit_link.js +++ b/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: ''}, 'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' }, - 'add-panel': { html: '' }, 'import': { html: '', isModal: true }, 'permissions': { html: '', isModal: true }, 'new-folder': { diff --git a/public/app/core/routes/dashboard_loaders.ts b/public/app/core/routes/dashboard_loaders.ts index bacd387456e..4e32a378a3b 100644 --- a/public/app/core/routes/dashboard_loaders.ts +++ b/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); } diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index a068cd4a789..90612d4c4e2 100644 --- a/public/app/core/routes/routes.ts +++ b/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, }) diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 062eea3506a..362b20fc307 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/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); + } }); } } diff --git a/public/app/core/services/popover_srv.ts b/public/app/core/services/popover_srv.ts index bfbd9e9950e..bf0dae2c631 100644 --- a/public/app/core/services/popover_srv.ts +++ b/public/app/core/services/popover_srv.ts @@ -68,6 +68,13 @@ function popoverSrv($compile, $rootScope, $timeout) { openDrop = drop; openDrop.open(); }, 100); + + // return close function + return function() { + if (drop) { + drop.close(); + } + }; }; } diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index 4828eb671a6..03d42c382f7 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/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 { diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts index a52e241ce35..f83de99a8c6 100644 --- a/public/app/features/annotations/editor_ctrl.ts +++ b/public/app/features/annotations/editor_ctrl.ts @@ -26,7 +26,7 @@ export class AnnotationsEditorCtrl { ]; /** @ngInject */ - constructor(private $scope, private datasourceSrv) { + constructor($scope, private datasourceSrv) { $scope.ctrl = this; this.mode = 'list'; @@ -62,7 +62,6 @@ export class AnnotationsEditorCtrl { update() { this.reset(); this.mode = 'list'; - this.$scope.broadcastRefresh(); } setupNew() { @@ -70,32 +69,24 @@ export class AnnotationsEditorCtrl { this.reset(); } + backToList() { + this.mode = 'list'; + } + add() { this.annotations.push(this.currentAnnotation); this.reset(); this.mode = 'list'; - this.$scope.broadcastRefresh(); - this.$scope.dashboard.updateSubmenuVisibility(); } removeAnnotation(annotation) { var index = _.indexOf(this.annotations, annotation); this.annotations.splice(index, 1); - this.$scope.dashboard.updateSubmenuVisibility(); - this.$scope.broadcastRefresh(); } onColorChange(newColor) { this.currentAnnotation.iconColor = newColor; } - - annotationEnabledChange() { - this.$scope.broadcastRefresh(); - } - - annotationHiddenChanged() { - this.$scope.dashboard.updateSubmenuVisibility(); - } } coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl); diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html index 4c0b8f7b127..62175e5278f 100644 --- a/public/app/features/annotations/partials/editor.html +++ b/public/app/features/annotations/partials/editor.html @@ -1,145 +1,113 @@ +
-
-

- Annotations -

+

+ Annotations + > New + > Edit +

- - - -
- -
- -
-
-
What are Annotations?
-

- Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons - on all graph panels. When you hover over an annotation icon you can get title, tags, and text information for the event. - In the Queries tab you can add queries that return annotation events. -

-

- You can add annotations directly from grafana by holding CTRL or CMD + click on graph (or drag region). These will be stored in Grafana's annotation database. -

- Checkout the Annotations documentation for more information. -
+
+
+
+ New
-
-
- No annotation queries defined -
- +
+ + + + + + + + - - + - -
Query nameData source
+   {{annotation.name}} +   {{annotation.name}} (Built-in) + {{annotation.datasource || 'Default'}} + - - - Edit - -
-
+ + -
-
-   New -
-
- -
-
-
-
General
-
-
- Name - -
-
- Data source -
- -
-
-
-
- -
-
- - - - -
- - - - -
-
-
-
- -
Query
- - - - - -
-
- - + +
+
+
There are no custom annotation queries added yet
+ + + Add Annotation Query + +
+
What are Annotations?
+

+ Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons + on all graph panels. When you hover over an annotation icon you can get event text & tags for the event. You can add annotation events + directly from grafana by holding CTRL or CMD + click on graph (or drag region). These will be stored in Grafana's annotation database. +

+ Checkout the Annotations documentation for more information.
+ +
+
+
General
+
+
+ Name + +
+
+ Data source +
+ +
+
+
+
+ +
+
+ + + + +
+ + + + +
+
+
+ +
Query
+ + + + + +
+
+ + +
+
+
diff --git a/public/app/features/dashboard/acl/acl.html b/public/app/features/dashboard/acl/acl.html index 8dfa86f958a..aa73f607684 100644 --- a/public/app/features/dashboard/acl/acl.html +++ b/public/app/features/dashboard/acl/acl.html @@ -52,7 +52,7 @@
- +
@@ -101,9 +101,9 @@ - + - + diff --git a/public/app/features/dashboard/acl/acl.ts b/public/app/features/dashboard/acl/acl.ts index 95fe45a40c8..55c2eb98572 100644 --- a/public/app/features/dashboard/acl/acl.ts +++ b/public/app/features/dashboard/acl/acl.ts @@ -12,7 +12,7 @@ export class AclCtrl { {value: 4, text: 'Admin'} ]; aclTypes = [ - {value: 'Group', text: 'User Group'}, + {value: 'Group', text: 'Team'}, {value: 'User', text: 'User'}, {value: 'Viewer', text: 'Everyone With Viewer Role'}, {value: 'Editor', text: 'Everyone With Editor Role'} @@ -58,10 +58,10 @@ export class AclCtrl { item.nameHtml = this.$sce.trustAsHtml(item.userLogin); item.sortName = item.userLogin; item.sortRank = 10; - } else if (item.userGroupId > 0) { + } else if (item.teamId > 0) { item.icon = "fa fa-fw fa-users"; - item.nameHtml = this.$sce.trustAsHtml(item.userGroup); - item.sortName = item.userGroup; + item.nameHtml = this.$sce.trustAsHtml(item.team); + item.sortName = item.team; item.sortRank = 20; } else if (item.role) { item.icon = "fa fa-fw fa-street-view"; @@ -89,7 +89,7 @@ export class AclCtrl { updated.push({ id: item.id, userId: item.userId, - userGroupId: item.userGroupId, + teamId: item.teamId, role: item.role, permission: item.permission, }); @@ -144,7 +144,7 @@ export class AclCtrl { return (origItem.role && newItem.role && origItem.role === newItem.role) || (origItem.userId && newItem.userId && origItem.userId === newItem.userId) || - (origItem.userGroupId && newItem.userGroupId && origItem.userGroupId === newItem.userGroupId); + (origItem.teamId && newItem.teamId && origItem.teamId === newItem.teamId); } userPicked(user) { @@ -153,8 +153,8 @@ export class AclCtrl { } groupPicked(group) { - this.addNewItem({userGroupId: group.id, userGroup: group.name, permission: 1}); - this.$scope.$broadcast('user-group-picker-reset'); + this.addNewItem({teamId: group.id, team: group.name, permission: 1}); + this.$scope.$broadcast('team-picker-reset'); } removeItem(index) { @@ -179,7 +179,7 @@ export function dashAclModal() { export interface FormModel { dashboardId: number; userId?: number; - userGroupId?: number; + teamId?: number; PermissionType: number; } @@ -189,8 +189,8 @@ export interface DashboardAcl { userId?: number; userLogin?: string; userEmail?: string; - userGroupId?: number; - userGroup?: string; + teamId?: number; + team?: string; permission?: number; permissionName?: string; role?: string; diff --git a/public/app/features/dashboard/acl/specs/acl_specs.ts b/public/app/features/dashboard/acl/specs/acl_specs.ts index 479db4b4de9..4c746ee9597 100644 --- a/public/app/features/dashboard/acl/specs/acl_specs.ts +++ b/public/app/features/dashboard/acl/specs/acl_specs.ts @@ -40,12 +40,12 @@ describe('AclCtrl', () => { ctx.ctrl.userPicked(userItem); - const userGroupItem = { + const teamItem = { id: 2, name: 'ug1', }; - ctx.ctrl.groupPicked(userGroupItem); + ctx.ctrl.groupPicked(teamItem); ctx.ctrl.newType = 'Editor'; ctx.ctrl.typeChanged(); @@ -54,10 +54,10 @@ describe('AclCtrl', () => { ctx.ctrl.typeChanged(); }); - it('should sort the result by role, user group and user', () => { + it('should sort the result by role, team and user', () => { expect(ctx.ctrl.items[0].role).to.eql('Viewer'); expect(ctx.ctrl.items[1].role).to.eql('Editor'); - expect(ctx.ctrl.items[2].userGroupId).to.eql(2); + expect(ctx.ctrl.items[2].teamId).to.eql(2); expect(ctx.ctrl.items[3].userId).to.eql(2); }); @@ -71,7 +71,7 @@ describe('AclCtrl', () => { expect(backendSrv.post.getCall(0).args[1].items[0].permission).to.eql(1); expect(backendSrv.post.getCall(0).args[1].items[1].role).to.eql('Editor'); expect(backendSrv.post.getCall(0).args[1].items[1].permission).to.eql(1); - expect(backendSrv.post.getCall(0).args[1].items[2].userGroupId).to.eql(2); + expect(backendSrv.post.getCall(0).args[1].items[2].teamId).to.eql(2); expect(backendSrv.post.getCall(0).args[1].items[2].permission).to.eql(1); expect(backendSrv.post.getCall(0).args[1].items[3].userId).to.eql(2); expect(backendSrv.post.getCall(0).args[1].items[3].permission).to.eql(1); @@ -124,19 +124,19 @@ describe('AclCtrl', () => { }); }); - describe('when duplicate user group permissions are added', () => { + describe('when duplicate team permissions are added', () => { beforeEach(() => { backendSrv.get.reset(); backendSrv.post.reset(); ctx.ctrl.items = []; - const userGroupItem = { + const teamItem = { id: 2, name: 'ug1', }; - ctx.ctrl.groupPicked(userGroupItem); - ctx.ctrl.groupPicked(userGroupItem); + ctx.ctrl.groupPicked(teamItem); + ctx.ctrl.groupPicked(teamItem); }); it('should throw a validation error', () => { @@ -148,25 +148,25 @@ describe('AclCtrl', () => { }); }); - describe('when one inherited and one not inherited user group permission are added', () => { + describe('when one inherited and one not inherited team permission are added', () => { beforeEach(() => { backendSrv.get.reset(); backendSrv.post.reset(); ctx.ctrl.items = []; - const inheritedUserGroupItem = { + const inheritedTeamItem = { id: 2, name: 'ug1', dashboardId: -1 }; - ctx.ctrl.items.push(inheritedUserGroupItem); + ctx.ctrl.items.push(inheritedTeamItem); - const userGroupItem = { + const teamItem = { id: 2, name: 'ug1', }; - ctx.ctrl.groupPicked(userGroupItem); + ctx.ctrl.groupPicked(teamItem); }); it('should not throw a validation error', () => { diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts index f21338f5d6a..e9ca2a854be 100644 --- a/public/app/features/dashboard/all.ts +++ b/public/app/features/dashboard/all.ts @@ -19,15 +19,16 @@ import './export/export_modal'; import './export_data/export_data_modal'; import './ad_hoc_filters'; import './repeat_option/repeat_option'; -import './dashgrid/DashboardGrid'; +import './dashgrid/DashboardGridDirective'; import './dashgrid/PanelLoader'; import './dashgrid/RowOptions'; import './acl/acl'; import './folder_picker/picker'; import './folder_modal/folder'; import './move_to_folder_modal/move_to_folder'; -import coreModule from 'app/core/core_module'; +import './settings/settings'; +import coreModule from 'app/core/core_module'; import {DashboardListCtrl} from './dashboard_list_ctrl'; import {FolderDashboardsCtrl} from './folder_dashboards_ctrl'; import {FolderPermissionsCtrl} from './folder_permissions_ctrl'; diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/dashboard/create_folder_ctrl.ts index f194dabbf1e..69d03e21b5e 100644 --- a/public/app/features/dashboard/create_folder_ctrl.ts +++ b/public/app/features/dashboard/create_folder_ctrl.ts @@ -7,7 +7,7 @@ export class CreateFolderCtrl { titleTouched = false; constructor(private backendSrv, private $location, navModelSrv) { - this.navModel = navModelSrv.getNav('create', 'folder'); + this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 0); } create() { diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index 2c9d028ddf8..2909c55bad5 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -122,12 +122,6 @@ export class DashboardCtrl implements PanelContainer { this.$rootScope.$broadcast("refresh"); } - onFolderChange(folder) { - this.dashboard.folderId = folder.id; - this.dashboard.meta.folderId = folder.id; - this.dashboard.meta.folderTitle= folder.title; - } - getPanelContainer() { return this; } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 10b0d0da67b..034ad7d84e1 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -22,7 +22,6 @@ export class DashboardModel { graphTooltip: any; time: any; timepicker: any; - hideControls: any; templating: any; annotations: any; refresh: any; @@ -67,7 +66,6 @@ export class DashboardModel { this.timezone = data.timezone || ''; this.editable = data.editable !== false; this.graphTooltip = data.graphTooltip || 0; - this.hideControls = data.hideControls || false; this.time = data.time || {from: 'now-6h', to: 'now'}; this.timepicker = data.timepicker || {}; this.templating = this.ensureListExist(data.templating); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 7b0f040e199..c640e06cc9b 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import coreModule from 'app/core/core_module'; import ReactGridLayout from 'react-grid-layout'; import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT} from 'app/core/constants'; import {DashboardPanel} from './DashboardPanel'; @@ -174,6 +173,3 @@ export class DashboardGrid extends React.Component { } } -coreModule.directive('dashboardGrid', function(reactDirective) { - return reactDirective(DashboardGrid, [['getPanelContainer', {watchDepth: 'reference', wrapApply: false}]]); -}); diff --git a/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts b/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts new file mode 100644 index 00000000000..84a0e0653e8 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts @@ -0,0 +1,4 @@ +import { react2AngularDirective } from 'app/core/utils/react2angular'; +import { DashboardGrid } from './DashboardGrid'; + +react2AngularDirective('dashboardGrid', DashboardGrid, [['getPanelContainer', {watchDepth: 'reference', wrapApply: false}]]); diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/dashnav/dashnav.html index 62974fb77dd..03453611036 100644 --- a/public/app/features/dashboard/dashnav/dashnav.html +++ b/public/app/features/dashboard/dashnav/dashnav.html @@ -27,40 +27,35 @@ - - - - - +
+ +
diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index 2b174e2c301..aed803efb9a 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -1,4 +1,3 @@ -import _ from 'lodash'; import moment from 'moment'; import angular from 'angular'; import {appEvents, NavModel} from 'app/core/core'; @@ -15,13 +14,11 @@ export class DashNavCtrl { private $rootScope, private dashboardSrv, private $location, - private backendSrv, public playlistSrv, navModelSrv) { this.navModel = navModelSrv.getDashboardNav(this.dashboard, this); appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope); - appEvents.on('delete-dashboard', this.deleteDashboard.bind(this), $scope); if (this.dashboard.meta.isSnapshot) { var meta = this.dashboard.meta; @@ -32,13 +29,26 @@ export class DashNavCtrl { } } - openEditView(editview) { - var search = _.extend(this.$location.search(), {editview: editview}); + toggleSettings() { + let search = this.$location.search(); + if (search.editview) { + delete search.editview; + } else { + search.editview = 'settings'; + } this.$location.search(search); } - showHelpModal() { - appEvents.emit('show-modal', {templateHtml: ''}); + close() { + let search = this.$location.search(); + if (search.editview) { + delete search.editview; + } + if (search.fullscreen) { + delete search.fullscreen; + delete search.edit; + } + this.$location.search(search); } starDashboard() { @@ -63,73 +73,10 @@ export class DashNavCtrl { angular.element(evt.currentTarget).tooltip('hide'); } - makeEditable() { - this.dashboard.editable = true; - - return this.dashboardSrv.saveDashboard({makeEditable: true, overwrite: false}).then(() => { - // force refresh whole page - window.location.href = window.location.href; - }); - } - - exitFullscreen() { - this.$rootScope.appEvent('panel-change-view', {fullscreen: false, edit: false}); - } - saveDashboard() { return this.dashboardSrv.saveDashboard(); } - deleteDashboard() { - var confirmText = ''; - var text2 = this.dashboard.title; - - const alerts = _.sumBy(this.dashboard.panels, panel => { - return panel.alert ? 1 : 0; - }); - - if (alerts > 0) { - confirmText = 'DELETE'; - text2 = `This dashboard contains ${alerts} alerts. Deleting this dashboard will also delete those alerts`; - } - - appEvents.emit('confirm-modal', { - title: 'Delete', - text: 'Do you want to delete this dashboard?', - text2: text2, - icon: 'fa-trash', - confirmText: confirmText, - yesText: 'Delete', - onConfirm: () => { - this.dashboard.meta.canSave = false; - this.deleteDashboardConfirmed(); - } - }); - } - - deleteDashboardConfirmed() { - this.backendSrv.delete('/api/dashboards/db/' + this.dashboard.meta.slug).then(() => { - appEvents.emit('alert-success', ['Dashboard Deleted', this.dashboard.title + ' has been deleted']); - this.$location.url('/'); - }); - } - - saveDashboardAs() { - return this.dashboardSrv.showSaveAsModal(); - } - - viewJson() { - var clone = this.dashboard.getSaveModelClone(); - - this.$rootScope.appEvent('show-json-editor', { - object: clone, - }); - } - - onFolderChange(folderId) { - this.dashboard.folderId = folderId; - } - showSearch() { this.$rootScope.appEvent('show-dash-search'); } diff --git a/public/app/features/dashboard/history/history.html b/public/app/features/dashboard/history/history.html index 7968f1162c5..48caddeb90e 100644 --- a/public/app/features/dashboard/history/history.html +++ b/public/app/features/dashboard/history/history.html @@ -1,146 +1,118 @@ -
-

- Version history -

+

+ Versions + + > Comparing {{ctrl.baseInfo.version}} + + {{ctrl.newInfo.version}} + (Latest) + +

- +
+
+ + Fetching history list… +
- -
+
+
+ + + + + + + + + + + + + + + + + + + + + +
VersionDateUpdated ByNotes
+ + + {{revision.version}}{{revision.createdDateString}}{{revision.createdBy}}{{revision.message}} + +   Restore + + +   Latest + +
-
- -
-
- - Fetching history list… -
- -
-
- - - - - - - - - - - - - - - - - - - - - -
VersionDateUpdated ByNotes
- - - {{revision.version}}{{revision.createdDateString}}{{revision.createdBy}}{{revision.message}} - -   Restore - - -   Latest - -
- -
- - Fetching more entries… -
- -
-
- - -
-
-
-
-
- -
- - -
-
+
- Fetching changes… + Fetching more entries…
-
- -   Restore to version {{ctrl.baseInfo.version}} - -

- Comparing Version {{ctrl.baseInfo.version}} - - Version {{ctrl.newInfo.version}} - (Latest) -

-
-

- Version {{ctrl.newInfo.version}} updated by - {{ctrl.newInfo.createdBy}} - {{ctrl.newInfo.ageString}} - - {{ctrl.newInfo.message}} -

-

- Version {{ctrl.baseInfo.version}} updated by - {{ctrl.baseInfo.createdBy}} - {{ctrl.baseInfo.ageString}} - - {{ctrl.baseInfo.message}} -

-
-
-
-
+
+
+ +
+ +
+
+ + Fetching changes… +
+ +
+ +
+

+ Version {{ctrl.newInfo.version}} updated by + {{ctrl.newInfo.createdBy}} + {{ctrl.newInfo.ageString}} + - {{ctrl.newInfo.message}} +

+

+ Version {{ctrl.baseInfo.version}} updated by + {{ctrl.baseInfo.createdBy}} + {{ctrl.baseInfo.ageString}} + - {{ctrl.baseInfo.message}} +

+
+ +
+
+
+ +
+ +
+ +
+
+
diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/history/history.ts index b0dee045a6a..05e78d08989 100644 --- a/public/app/features/dashboard/history/history.ts +++ b/public/app/features/dashboard/history/history.ts @@ -1,5 +1,3 @@ -/// - import './history_srv'; import _ from 'lodash'; diff --git a/public/app/features/dashboard/partials/addAnnotationModal.html b/public/app/features/dashboard/partials/addAnnotationModal.html deleted file mode 100644 index f55f888375f..00000000000 --- a/public/app/features/dashboard/partials/addAnnotationModal.html +++ /dev/null @@ -1,65 +0,0 @@ - -
diff --git a/public/app/features/dashboard/partials/create_folder.html b/public/app/features/dashboard/partials/create_folder.html index ae5fcc126e8..22dbaab1419 100644 --- a/public/app/features/dashboard/partials/create_folder.html +++ b/public/app/features/dashboard/partials/create_folder.html @@ -2,42 +2,42 @@
- +
+
+ +
+
+ +
+ +
+
diff --git a/public/app/features/dashboard/partials/settings.html b/public/app/features/dashboard/partials/settings.html deleted file mode 100644 index 77d6691f8f9..00000000000 --- a/public/app/features/dashboard/partials/settings.html +++ /dev/null @@ -1,95 +0,0 @@ -
-

- Settings -

- - - - -
- -
-
- -
-
Details
-
- - -
-
- - -
-
- - - -
- - -
- -
-
Options
-
-
- -
- -
-
- - - - -
-
- -
-
Panel Options
-
- -
- -
-
-
-
- -
- -
- -
- -
- -
diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/save_as_modal.ts index 643d6a36f67..d69b64f1960 100644 --- a/public/app/features/dashboard/save_as_modal.ts +++ b/public/app/features/dashboard/save_as_modal.ts @@ -1,5 +1,3 @@ -/// - import coreModule from 'app/core/core_module'; const template = ` diff --git a/public/app/features/dashboard/settings/settings.html b/public/app/features/dashboard/settings/settings.html new file mode 100644 index 00000000000..7de5e2dbcd1 --- /dev/null +++ b/public/app/features/dashboard/settings/settings.html @@ -0,0 +1,109 @@ + + +
+

+ General +

+ +
+
+ + +
+
+ + +
+
+ + + +
+ + + + +
+ + + +
Panel Options
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+

View JSON

+ +
+ +
+
+ +
+

Settings view not found

+ +
+
The settings page could not be found or you do not have permission to access it
+
+
+ +
+

Make Editable

+ + +
+ diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts new file mode 100644 index 00000000000..87ac91d568b --- /dev/null +++ b/public/app/features/dashboard/settings/settings.ts @@ -0,0 +1,156 @@ +import { coreModule, appEvents, contextSrv } from 'app/core/core'; +import { DashboardModel } from '../dashboard_model'; +import $ from 'jquery'; +import _ from 'lodash'; + +export class SettingsCtrl { + dashboard: DashboardModel; + isOpen: boolean; + viewId: string; + json: string; + alertCount: number; + canSaveAs: boolean; + canDelete: boolean; + sections: any[]; + + /** @ngInject */ + constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) { + // temp hack for annotations and variables editors + // that rely on inherited scope + $scope.dashboard = this.dashboard; + + this.$scope.$on('$destroy', () => { + this.dashboard.updateSubmenuVisibility(); + this.$rootScope.$broadcast('refresh'); + }); + + this.canSaveAs = contextSrv.isEditor; + this.canDelete = this.dashboard.meta.canSave; + + this.buildSectionList(); + this.onRouteUpdated(); + + $rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope); + } + + buildSectionList() { + this.sections = []; + if (this.dashboard.meta.canEdit) { + this.sections.push({ title: 'General', id: 'settings', icon: 'fa fa-fw fa-sliders' }); + this.sections.push({ title: 'Annotations', id: 'annotations', icon: 'fa fa-fw fa-comment-o' }); + this.sections.push({ title: 'Variables', id: 'templating', icon: 'fa fa-fw fa-dollar' }); + this.sections.push({ title: 'Links', id: 'links', icon: 'fa fa-fw fa-external-link' }); + + if (this.dashboard.id) { + this.sections.push({ title: 'Versions', id: 'versions', icon: 'fa fa-fw fa-history' }); + } + } + + if (contextSrv.isEditor && !this.dashboard.editable) { + this.sections.push({ title: 'Make Editable', icon: 'fa fa-fw fa-edit', id: 'make_editable' }); + this.viewId = 'make_editable'; + } + + this.sections.push({ title: 'View JSON', id: 'view_json', icon: 'fa fa-fw fa-code' }); + + const params = this.$location.search(); + const url = this.$location.path(); + + for (let section of this.sections) { + const sectionParams = _.defaults({ editview: section.id }, params); + section.url = url + '?' + $.param(sectionParams); + } + } + + onRouteUpdated() { + this.viewId = this.$location.search().editview; + + if (this.viewId) { + this.json = JSON.stringify(this.dashboard.getSaveModelClone(), null, 2); + } + + const currentSection = _.find(this.sections, { id: this.viewId }); + if (!currentSection) { + this.sections.unshift({ title: 'Not found', id: '404', icon: 'fa fa-fw fa-warning' }); + this.viewId = '404'; + return; + } + } + + openSaveAsModal() { + this.dashboardSrv.showSaveAsModal(); + } + + hideSettings() { + var urlParams = this.$location.search(); + delete urlParams.editview; + setTimeout(() => { + this.$rootScope.$apply(() => { + this.$location.search(urlParams); + }); + }); + } + + makeEditable() { + this.dashboard.editable = true; + + return this.dashboardSrv.saveDashboard({ makeEditable: true, overwrite: false }).then(() => { + // force refresh whole page + window.location.href = window.location.href; + }); + } + + deleteDashboard() { + var confirmText = ''; + var text2 = this.dashboard.title; + + const alerts = _.sumBy(this.dashboard.panels, panel => { + return panel.alert ? 1 : 0; + }); + + if (alerts > 0) { + confirmText = 'DELETE'; + text2 = `This dashboard contains ${alerts} alerts. Deleting this dashboard will also delete those alerts`; + } + + appEvents.emit('confirm-modal', { + title: 'Delete', + text: 'Do you want to delete this dashboard?', + text2: text2, + icon: 'fa-trash', + confirmText: confirmText, + yesText: 'Delete', + onConfirm: () => { + this.dashboard.meta.canSave = false; + this.deleteDashboardConfirmed(); + } + }); + } + + deleteDashboardConfirmed() { + this.backendSrv.delete('/api/dashboards/db/' + this.dashboard.meta.slug).then(() => { + appEvents.emit('alert-success', ['Dashboard Deleted', this.dashboard.title + ' has been deleted']); + this.$location.url('/'); + }); + } + + onFolderChange(folder) { + this.dashboard.folderId = folder.id; + this.dashboard.meta.folderId = folder.id; + this.dashboard.meta.folderTitle = folder.title; + } +} + +export function dashboardSettings() { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/settings/settings.html', + controller: SettingsCtrl, + bindToController: true, + controllerAs: 'ctrl', + transclude: true, + scope: { dashboard: '=' }, + }; +} + +coreModule.directive('dashboardSettings', dashboardSettings); diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html index 9e9d22fb495..f240a86efba 100644 --- a/public/app/features/dashboard/submenu/submenu.html +++ b/public/app/features/dashboard/submenu/submenu.html @@ -1,5 +1,4 @@