Merge branch 'develop' into 9879-login

This commit is contained in:
Johannes Schill 2017-12-13 09:22:48 +01:00
commit 6a2b1e52b2
184 changed files with 3895 additions and 2362 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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)

View File

@ -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!

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

@ -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.

View File

@ -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`.

View File

@ -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

View File

@ -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.

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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),
}
}

View File

@ -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 {

View File

@ -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(),

View File

@ -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)
}

View File

@ -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)

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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",

View File

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

92
pkg/api/team.go Normal file
View File

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

44
pkg/api/team_members.go Normal file
View File

@ -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")
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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
}
}

View File

@ -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 == "" {

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

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

68
pkg/models/team.go Normal file
View File

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

55
pkg/models/team_member.go Normal file
View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
})
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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)
}

View File

@ -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"}},

View File

@ -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)
}

View File

@ -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

View File

@ -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)
})
})

View File

@ -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

View File

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

View File

@ -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]))
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
})
})
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
})
})
})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 := ""

View File

@ -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)

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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');
}
});
}
};
});

View File

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

View File

@ -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);
}

View File

@ -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,
})

View File

@ -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);
}
});
}
}

View File

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

View File

@ -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 {

Some files were not shown because too many files have changed in this diff Show More