diff --git a/.jshintrc b/.jshintrc index 3fb6501c2f9..1d8fad63173 100644 --- a/.jshintrc +++ b/.jshintrc @@ -4,7 +4,7 @@ "bitwise":false, "curly": true, "eqnull": true, - "strict": true, + "strict": false, "devel": true, "eqeqeq": true, "forin": false, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6508c4ff76a..8d16e816f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,34 @@ -# 5.0.0-beta2 (unrelased) +# 5.0.0-beta4 (2018-02-19) + +### Fixes + +- **Dashboard** Fixed dashboard overwrite permission issue [#10814](https://github.com/grafana/grafana/issues/10814) +- **Keyboard shortcuts** Fixed Esc key when in panel edit/view mode [#10945](https://github.com/grafana/grafana/issues/10945) +- **Save dashboard** Fixed issue with time range & variable reset after saving [#10946](https://github.com/grafana/grafana/issues/10946) + +# 5.0.0-beta3 (2018-02-16) + +### Fixes + +- **MySQL** Fixed new migration issue with index length [#10931](https://github.com/grafana/grafana/issues/10931) +- **Modal** Escape key no closes modals everywhere, fixes [#10887](https://github.com/grafana/grafana/issues/10887) +- **Row repeats** Fix for repeating rows issue, fixes [#10932](https://github.com/grafana/grafana/issues/10932) +- **Docs** Team api documented, fixes [#10832](https://github.com/grafana/grafana/issues/10832) +- **Plugins** Plugin info page broken, fixes [#10943](https://github.com/grafana/grafana/issues/10943) + +# 5.0.0-beta2 (2018-02-15) + +### Fixes + +- **Permissions** Fixed search permissions issues [#10822](https://github.com/grafana/grafana/issues/10822) +- **Permissions** Fixed problem issues displaying permissions lists [#10864](https://github.com/grafana/grafana/issues/10864) +- **PNG-Rendering** Fixed problem rendering legend to the right [#10526](https://github.com/grafana/grafana/issues/10526) +- **Reset password** Fixed problem with reset password form [#10870](https://github.com/grafana/grafana/issues/10870) +- **Light theme** Fixed problem with light theme in safari, [#10869](https://github.com/grafana/grafana/issues/10869) +- **Provisioning** Now handles deletes when dashboard json files removed from disk [#10865](https://github.com/grafana/grafana/issues/10865) +- **MySQL** Fixed issue with schema migration on old mysql (index too long) [#10779](https://github.com/grafana/grafana/issues/10779) +- **Github OAuth** Fixed fetching github orgs from private github org [#10823](https://github.com/grafana/grafana/issues/10823) +- **Embedding** Fixed issues embedding panel [#10787](https://github.com/grafana/grafana/issues/10787) # 5.0.0-beta1 (2018-02-05) diff --git a/README.md b/README.md index 80a80b694c1..81fb1f8d42b 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,11 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = ### Running tests -- You can run backend Golang tests using "go test ./pkg/...". -- Execute all frontend tests with "npm run test" +#### Frontend +Execute all frontend tests +```bash +npm run test +``` Writing & watching frontend tests (we have two test runners) @@ -92,6 +95,18 @@ Writing & watching frontend tests (we have two test runners) - Start watcher: `npm run karma` - Karma+Mocha runs all files that end with the name "_specs.ts". +#### Backend +```bash +# Run Golang tests using sqlite3 as database (default) +go test ./pkg/... + +# Run Golang tests using mysql as database - convenient to use /docker/blocks/mysql_tests +GRAFANA_TEST_DB=mysql go test ./pkg/... + +# Run Golang tests using postgres as database - convenient to use /docker/blocks/postgres_tests +GRAFANA_TEST_DB=postgres go test ./pkg/... +``` + ## Contribute If you have any idea for an improvement or found a bug, do not hesitate to open an issue. diff --git a/conf/defaults.ini b/conf/defaults.ini index 3766c829323..86768738171 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -327,7 +327,7 @@ allow_sign_up = true enabled = false host = localhost:25 user = -# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" password = cert_file = key_file = diff --git a/conf/ldap.toml b/conf/ldap.toml index ae217106cb2..166d85eabb1 100644 --- a/conf/ldap.toml +++ b/conf/ldap.toml @@ -19,7 +19,7 @@ ssl_skip_verify = false # Search user bind dn bind_dn = "cn=admin,dc=grafana,dc=org" # Search user bind password -# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" bind_password = 'grafana' # User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" diff --git a/conf/provisioning/dashboards/sample.yaml b/conf/provisioning/dashboards/sample.yaml index 40992d1461e..d70bd425634 100644 --- a/conf/provisioning/dashboards/sample.yaml +++ b/conf/provisioning/dashboards/sample.yaml @@ -1,6 +1,10 @@ +# # config file version +apiVersion: 1 + +#providers: # - name: 'default' -# org_id: 1 +# orgId: 1 # folder: '' # type: file # options: -# folder: /var/lib/grafana/dashboards \ No newline at end of file +# path: /var/lib/grafana/dashboards diff --git a/conf/provisioning/datasources/sample.yaml b/conf/provisioning/datasources/sample.yaml index 1bb9cb53b45..877e229183d 100644 --- a/conf/provisioning/datasources/sample.yaml +++ b/conf/provisioning/datasources/sample.yaml @@ -1,10 +1,13 @@ +# # config file version +apiVersion: 1 + # # list of datasources that should be deleted from the database -#delete_datasources: +#deleteDatasources: # - name: Graphite -# org_id: 1 +# orgId: 1 # # list of datasources to insert/update depending -# # whats available in the datbase +# # on what's available in the datbase #datasources: # # name of the datasource. Required # - name: Graphite @@ -12,8 +15,8 @@ # type: graphite # # access mode. direct or proxy. Required # access: proxy -# # org id. will default to org_id 1 if not specified -# org_id: 1 +# # org id. will default to orgId 1 if not specified +# orgId: 1 # # url # url: http://localhost:8080 # # database password, if used @@ -23,22 +26,22 @@ # # database name, if used # database: # # enable/disable basic auth -# basic_auth: +# basicAuth: # # basic auth username -# basic_auth_user: +# basicAuthUser: # # basic auth password -# basic_auth_password: +# basicAuthPassword: # # enable/disable with credentials headers -# with_credentials: +# withCredentials: # # mark as default datasource. Max one per org -# is_default: +# isDefault: # # fields that will be converted to json and stored in json_data -# json_data: +# jsonData: # graphiteVersion: "1.1" # tlsAuth: true # tlsAuthWithCACert: true # # json object of data that will be encrypted. -# secure_json_data: +# secureJsonData: # tlsCACert: "..." # tlsClientCert: "..." # tlsClientKey: "..." diff --git a/conf/sample.ini b/conf/sample.ini index 784f6b7cfc9..5f13dad4061 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -71,7 +71,7 @@ ;host = 127.0.0.1:3306 ;name = grafana ;user = root -# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" ;password = # Use either URL or the previous fields to configure the database diff --git a/docker/blocks/mysql/dashboard.json b/docker/blocks/mysql/dashboard.json new file mode 100644 index 00000000000..e2b791f82e6 --- /dev/null +++ b/docker/blocks/mysql/dashboard.json @@ -0,0 +1,549 @@ +{ + "__inputs": [ + { + "name": "DS_MYSQL", + "label": "Mysql", + "description": "", + "type": "datasource", + "pluginId": "mysql", + "pluginName": "MySQL" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "mysql", + "name": "MySQL", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "A dashboard visualizing data generated from grafana/fake-data-gen", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1518602729468, + "links": [], + "panels": [ + { + "aliasColors": { + "total avg": "#6ed0e0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "total avg", + "fill": 0, + "pointradius": 3, + "points": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "hide": false, + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n avg(value) as value,\n hostname as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'logins.count' AND\n hostname IN($host)\nGROUP BY 1, 3\nORDER BY 1", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n min(value) as value,\n 'total avg' as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'logins.count'\nGROUP BY 1\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "title": "Average logins / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL}", + "fill": 2, + "gridPos": { + "h": 18, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n avg(value) as value,\n 'started' as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'payment.started'\nGROUP BY 1, 3\nORDER BY 1", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n avg(value) as value,\n 'ended' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'payment.ended'\nGROUP BY 1, 3\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "title": "Average payments started/ended / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n max(value) as value,\n hostname as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'cpu' AND\n hostname IN($host)\nGROUP BY 1, 3\nORDER BY 1", + "refId": "A", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "title": "Max CPU / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "columns": [], + "datasource": "${DS_MYSQL}", + "fontSize": "100%", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 6, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "link": false, + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "", + "format": "table", + "rawSql": "SELECT createdAt as Time, source, datacenter, hostname, value FROM grafana_metric WHERE hostname in($host)", + "refId": "A", + "target": "" + } + ], + "timeShift": "1h", + "title": "Values", + "transform": "table", + "type": "table" + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "fake-data-gen", + "mysql" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_MYSQL}", + "hide": 0, + "includeAll": false, + "label": "Datacenter", + "multi": false, + "name": "datacenter", + "options": [], + "query": "SELECT DISTINCT datacenter FROM grafana_metric", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "${DS_MYSQL}", + "hide": 0, + "includeAll": true, + "label": "Hostname", + "multi": true, + "name": "host", + "options": [], + "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 5, + "auto_min": "10s", + "current": { + "selected": true, + "text": "1m", + "value": "1m" + }, + "hide": 0, + "label": "Summarize", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": true, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "Grafana Fake Data Gen - MySQL", + "uid": "DGsCac3kz", + "version": 6 +} \ No newline at end of file diff --git a/docker/blocks/postgres/dashboard.json b/docker/blocks/postgres/dashboard.json new file mode 100644 index 00000000000..77b0ceac624 --- /dev/null +++ b/docker/blocks/postgres/dashboard.json @@ -0,0 +1,547 @@ +{ + "__inputs": [ + { + "name": "DS_POSTGRESQL", + "label": "PostgreSQL", + "description": "", + "type": "datasource", + "pluginId": "postgres", + "pluginName": "PostgreSQL" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "postgres", + "name": "PostgreSQL", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "A dashboard visualizing data generated from grafana/fake-data-gen", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1518601837383, + "links": [], + "panels": [ + { + "aliasColors": { + "total avg": "#6ed0e0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRESQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "total avg", + "fill": 0, + "pointradius": 3, + "points": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "hide": false, + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n avg(value) as \"value\",\n hostname as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'logins.count' AND\n hostname IN($host)\nGROUP BY time, metric\nORDER BY time", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n min(value) as \"value\",\n 'total avg' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'logins.count'\nGROUP BY time", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Average logins / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRESQL}", + "fill": 2, + "gridPos": { + "h": 18, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n avg(value) as \"value\",\n 'started' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'payment.started'\nGROUP BY time, metric\nORDER BY time", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n avg(value) as \"value\",\n 'ended' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'payment.ended'\nGROUP BY time, metric\nORDER BY time", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Average payments started/ended / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRESQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n max(value) as \"value\",\n hostname as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'cpu' AND\n hostname IN($host)\nGROUP BY time, metric\nORDER BY time", + "refId": "A", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Max CPU / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "columns": [], + "datasource": "${DS_POSTGRESQL}", + "fontSize": "100%", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 6, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "link": false, + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "", + "format": "table", + "rawSql": "SELECT \"createdAt\" as \"Time\", source, datacenter, hostname, value FROM grafana_metric WHERE hostname in($host)", + "refId": "A", + "target": "" + } + ], + "title": "Values", + "transform": "table", + "type": "table" + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "fake-data-gen", + "postgres" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_POSTGRESQL}", + "hide": 0, + "includeAll": false, + "label": "Datacenter", + "multi": false, + "name": "datacenter", + "options": [], + "query": "SELECT DISTINCT datacenter FROM grafana_metric", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "${DS_POSTGRESQL}", + "hide": 0, + "includeAll": true, + "label": "Hostname", + "multi": true, + "name": "host", + "options": [], + "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 5, + "auto_min": "10s", + "current": { + "text": "1m", + "value": "1m" + }, + "hide": 0, + "label": "Summarize", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": true, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "Grafana Fake Data Gen - PostgreSQL", + "uid": "JYola5qzz", + "version": 1 +} \ No newline at end of file diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index c3595969281..d213a786cd7 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -81,13 +81,16 @@ If you are running multiple instances of Grafana you might run into problems if ### Example datasource config file ```yaml +# config file version +apiVersion: 1 + # list of datasources that should be deleted from the database -delete_datasources: +deleteDatasources: - name: Graphite - org_id: 1 + orgId: 1 # list of datasources to insert/update depending -# whats available in the datbase +# whats available in the database datasources: # name of the datasource. Required - name: Graphite @@ -95,8 +98,8 @@ datasources: type: graphite # access mode. direct or proxy. Required access: proxy - # org id. will default to org_id 1 if not specified - org_id: 1 + # org id. will default to orgId 1 if not specified + orgId: 1 # url url: http://localhost:8080 # database password, if used @@ -106,22 +109,22 @@ datasources: # database name, if used database: # enable/disable basic auth - basic_auth: + basicAuth: # basic auth username - basic_auth_user: + basicAuthUser: # basic auth password - basic_auth_password: + basicAuthPassword: # enable/disable with credentials headers - with_credentials: + withCredentials: # mark as default datasource. Max one per org - is_default: + isDefault: # fields that will be converted to json and stored in json_data - json_data: + jsonData: graphiteVersion: "1.1" tlsAuth: true tlsAuthWithCACert: true # json object of data that will be encrypted. - secure_json_data: + secureJsonData: tlsCACert: "..." tlsClientCert: "..." tlsClientKey: "..." @@ -155,7 +158,7 @@ Since not all datasources have the same configuration settings we only have the #### Secure Json data -{"authType":"keys","defaultRegion":"us-west-2","timeField":"@timestamp"} +`{"authType":"keys","defaultRegion":"us-west-2","timeField":"@timestamp"}` Secure json data is a map of settings that will be encrypted with [secret key](/installation/configuration/#secret-key) from the Grafana config. The purpose of this is only to hide content from the users of the application. This should be used for storing TLS Cert and password that Grafana will append to the request on the server side. All of these settings are optional. @@ -169,17 +172,28 @@ Secure json data is a map of settings that will be encrypted with [secret key](/ ### 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. +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 from the local filesystem. The dashboard provider config file looks somewhat like this: ```yaml +apiVersion: 1 + +providers: - name: 'default' - org_id: 1 + orgId: 1 folder: '' type: file + disableDeletion: false + editable: false options: - folder: /var/lib/grafana/dashboards + path: /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. +By default Grafana will delete dashboards in the database if the file is removed. You can disable this behavior using the `disableDeletion` setting. + +> **Note.** Provisioning allows you to overwrite existing dashboards +> which leads to problems if you re-use settings that are supposed to be unique. +> Be careful not to re-use the same `title` multiple times within a folder +> or `uid` within the same installation as this will cause weird behaviours. diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index 648957ed96e..e955dbb9569 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -13,7 +13,7 @@ weight = 10 # Using AWS CloudWatch in Grafana -Grafana ships with built in support for CloudWatch. You just have to add it as a data source and you will be ready to build dashboards for you CloudWatch metrics. +Grafana ships with built in support for CloudWatch. You just have to add it as a data source and you will be ready to build dashboards for your CloudWatch metrics. ## Adding the data source to Grafana diff --git a/docs/sources/guides/whats-new-in-v5.md b/docs/sources/guides/whats-new-in-v5.md index bd960ed1694..fdc3c515a79 100644 --- a/docs/sources/guides/whats-new-in-v5.md +++ b/docs/sources/guides/whats-new-in-v5.md @@ -12,7 +12,7 @@ weight = -6 # What's New in Grafana v5.0 -> Out in beta: [Download now!](https://grafana.com/grafana/download/5.0.0-beta1) +> Out in beta: [Download now!](https://grafana.com/grafana/download/beta) This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements. diff --git a/docs/sources/http_api/dashboard.md b/docs/sources/http_api/dashboard.md index 0538754bd96..6ddb2360e03 100644 --- a/docs/sources/http_api/dashboard.md +++ b/docs/sources/http_api/dashboard.md @@ -83,7 +83,7 @@ Content-Length: 97 } ``` -In in case of title already exists the `status` property will be `name-exists`. +In case of title already exists the `status` property will be `name-exists`. ## Get dashboard diff --git a/docs/sources/index.md b/docs/sources/index.md index c1072db47a5..3c59b9baba0 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -1,49 +1,107 @@ +++ -title = "Docs Home" -description = "Install guide for Grafana" +title = "Grafana documentation" +description = "Guides, Installation & Feature Documentation" keywords = ["grafana", "installation", "documentation"] type = "docs" aliases = ["v1.1", "guides/reference/admin"] +++ -# Welcome to the Grafana Documentation +# Grafana Documentation -Grafana is an open source metric analytics & visualization suite. It is most commonly used for -visualizing time series data for infrastructure and application analytics but many use it in -other domains including industrial sensors, home automation, weather, and process control. +

Installing Grafana

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

Guides

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

Data Source Guides

+ diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index bfc7fdc0a3d..a45afe35e6b 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,7 +16,7 @@ weight = 1 Description | Download ------------ | ------------- Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb) -Beta for Debian-based Linux | [grafana_5.0.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb) +Beta for Debian-based Linux | [grafana_5.0.0-beta4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -33,9 +33,9 @@ sudo dpkg -i grafana_4.6.3_amd64.deb ## Install Latest Beta ```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_5.0.0-beta1_amd64.deb +sudo dpkg -i grafana_5.0.0-beta4_amd64.deb ``` ## APT Repository diff --git a/docs/sources/installation/ldap.md b/docs/sources/installation/ldap.md index 8f6be6e1d8c..85501e51d85 100644 --- a/docs/sources/installation/ldap.md +++ b/docs/sources/installation/ldap.md @@ -43,7 +43,7 @@ ssl_skip_verify = false # Search user bind dn bind_dn = "cn=admin,dc=grafana,dc=org" # Search user bind password -# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" bind_password = 'grafana' # User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index f0c498c819f..e97ab17a697 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -16,7 +16,7 @@ weight = 2 Description | Download ------------ | ------------- Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm) -Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm) +Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -32,7 +32,7 @@ $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/g ## Install Beta ```bash -$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm ``` Or install manually using `rpm`. diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 08d234d63f9..07f5abde52f 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -14,7 +14,7 @@ weight = 3 Description | Download ------------ | ------------- Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip) -Latest beta package for Windows | [grafana.5.0.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.windows-x64.zip) +Latest beta package for Windows | [grafana.5.0.0-beta4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/package.json b/package.json index aad7d2ce92c..1cae6360f01 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.0.0-beta1", + "version": "5.0.0-beta4", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -19,6 +19,7 @@ "angular-mocks": "^1.6.6", "autoprefixer": "^6.4.0", "awesome-typescript-loader": "^3.2.3", + "axios": "^0.17.1", "babel-core": "^6.26.0", "babel-loader": "^7.1.2", "babel-preset-es2015": "^6.24.1", @@ -105,6 +106,7 @@ "lint": "tslint -c tslint.json --project tsconfig.json --type-check", "karma": "node ./node_modules/grunt-cli/bin/grunt karma:dev", "jest": "node ./node_modules/jest-cli/bin/jest.js --notify --watch", + "api-tests": "node ./node_modules/jest-cli/bin/jest.js --notify --watch --config=tests/api/jest.js", "precommit": "lint-staged && node ./node_modules/grunt-cli/bin/grunt precommit" }, "lint-staged": { @@ -148,6 +150,7 @@ "mobx-state-tree": "^1.3.1", "moment": "^2.18.1", "mousetrap": "^1.6.0", + "mousetrap-global-bind": "^1.1.0", "perfect-scrollbar": "^1.2.0", "prop-types": "^15.6.0", "react": "^16.2.0", diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index ca5e7aea90c..5dea3655251 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=5.0.0-beta1 -rpm_ver=5.0.0-beta1 +deb_ver=5.0.0-beta4 +rpm_ver=5.0.0-beta4 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 16f5f7ceb6f..08edf54748b 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -52,6 +52,7 @@ func GetAlerts(c *middleware.Context) Response { DashboardId: c.QueryInt64("dashboardId"), PanelId: c.QueryInt64("panelId"), Limit: c.QueryInt64("limit"), + User: c.SignedInUser, } states := c.QueryStrings("state") @@ -63,74 +64,11 @@ func GetAlerts(c *middleware.Context) Response { return ApiError(500, "List alerts failed", err) } - alertDTOs, resp := transformToDTOs(query.Result, c) - if resp != nil { - return resp + for _, alert := range query.Result { + alert.Url = models.GetDashboardUrl(alert.DashboardUid, alert.DashboardSlug) } - return Json(200, alertDTOs) -} - -func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) { - if len(alerts) == 0 { - return []*dtos.AlertRule{}, nil - } - - dashboardIds := make([]int64, 0) - alertDTOs := make([]*dtos.AlertRule, 0) - for _, alert := range alerts { - dashboardIds = append(dashboardIds, alert.DashboardId) - alertDTOs = append(alertDTOs, &dtos.AlertRule{ - Id: alert.Id, - DashboardId: alert.DashboardId, - PanelId: alert.PanelId, - Name: alert.Name, - Message: alert.Message, - State: alert.State, - NewStateDate: alert.NewStateDate, - ExecutionError: alert.ExecutionError, - EvalData: alert.EvalData, - }) - } - - dashboardsQuery := models.GetDashboardsQuery{ - DashboardIds: dashboardIds, - } - - if err := bus.Dispatch(&dashboardsQuery); err != nil { - return nil, ApiError(500, "List alerts failed", err) - } - - //TODO: should be possible to speed this up with lookup table - for _, alert := range alertDTOs { - for _, dash := range dashboardsQuery.Result { - if alert.DashboardId == dash.Id { - alert.Url = dash.GenerateUrl() - break - } - } - } - - permissionsQuery := models.GetDashboardPermissionsForUserQuery{ - DashboardIds: dashboardIds, - OrgId: c.OrgId, - UserId: c.SignedInUser.UserId, - OrgRole: c.SignedInUser.OrgRole, - } - - if err := bus.Dispatch(&permissionsQuery); err != nil { - return nil, ApiError(500, "List alerts failed", err) - } - - for _, alert := range alertDTOs { - for _, perm := range permissionsQuery.Result { - if alert.DashboardId == perm.DashboardId { - alert.CanEdit = perm.Permission > 1 - } - } - } - - return alertDTOs, nil + return Json(200, query.Result) } // POST /api/alerts/test @@ -288,7 +226,7 @@ func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response { return ApiError(500, "Get Alert failed", err) } - guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser) + guardian := guardian.New(query.Result.DashboardId, c.OrgId, c.SignedInUser) if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { if err != nil { return ApiError(500, "Error while checking permissions for Alert", err) diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index e3845520795..cb1a15e69eb 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -278,7 +278,7 @@ func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error } if dashboardId > 0 { - guardian := guardian.NewDashboardGuardian(dashboardId, c.OrgId, c.SignedInUser) + guardian := guardian.New(dashboardId, c.OrgId, c.SignedInUser) if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { return false, err } diff --git a/pkg/api/api.go b/pkg/api/api.go index 752af7602f5..1320663f630 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -150,13 +150,13 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/teams", func(teamsRoute RouteRegister) { teamsRoute.Get("/:teamId", wrap(GetTeamById)) teamsRoute.Get("/search", wrap(SearchTeams)) - teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam)) - teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById)) - teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember)) - }) + teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) + teamsRoute.Delete("/:teamId", wrap(DeleteTeamById)) + teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers)) + teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) + }, reqOrgAdmin) // org information available to all users. apiRoute.Group("/org", func(orgRoute RouteRegister) { @@ -261,8 +261,6 @@ func (hs *HttpServer) registerRoutes() { dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) - dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser)) - dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) @@ -271,7 +269,6 @@ func (hs *HttpServer) registerRoutes() { dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { aclRoute.Get("/", wrap(GetDashboardAclList)) aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) - aclRoute.Delete("/:aclId", wrap(DeleteDashboardAcl)) }) }) }) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 1ecf16b8cb0..a079f6f7681 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path" - "strings" "github.com/grafana/grafana/pkg/services/dashboards" @@ -50,7 +49,7 @@ func GetDashboard(c *middleware.Context) Response { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canView, err := guardian.CanView(); err != nil || !canView { return dashboardGuardianResponse(err) } @@ -157,7 +156,7 @@ func DeleteDashboard(c *middleware.Context) Response { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -177,7 +176,7 @@ func DeleteDashboardByUid(c *middleware.Context) Response { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -197,32 +196,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { dash := cmd.GetDashboardModel() - dashId := dash.Id - - // if new dashboard, use parent folder permissions instead - if dashId == 0 { - dashId = cmd.FolderId - } - - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) - if canSave, err := guardian.CanSave(); err != nil || !canSave { - return dashboardGuardianResponse(err) - } - - if dash.IsFolder && dash.FolderId > 0 { - return ApiError(400, m.ErrDashboardFolderCannotHaveParent.Error(), nil) - } - - // Check if Title is empty - if dash.Title == "" { - return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil) - } - - if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(m.RootFolderName) { - return ApiError(400, "A folder already exists with that name", nil) - } - - if dash.Id == 0 { + if dash.Id == 0 && dash.Uid == "" { limitReached, err := middleware.QuotaReached(c, "dashboard") if err != nil { return ApiError(500, "failed to get quota", err) @@ -232,31 +206,38 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { } } - dashItem := &dashboards.SaveDashboardItem{ + dashItem := &dashboards.SaveDashboardDTO{ Dashboard: dash, Message: cmd.Message, OrgId: c.OrgId, - UserId: c.UserId, + User: c.SignedInUser, Overwrite: cmd.Overwrite, } - dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem) + dashboard, err := dashboards.NewService().SaveDashboard(dashItem) if err == m.ErrDashboardTitleEmpty || err == m.ErrDashboardWithSameNameAsFolder || err == m.ErrDashboardFolderWithSameNameAsDashboard || - err == m.ErrDashboardTypeMismatch { + err == m.ErrDashboardTypeMismatch || + err == m.ErrDashboardInvalidUid || + err == m.ErrDashboardUidToLong || + err == m.ErrDashboardWithSameUIDExists || + err == m.ErrFolderNotFound || + err == m.ErrDashboardFolderCannotHaveParent || + err == m.ErrDashboardFolderNameExists { return ApiError(400, err.Error(), nil) } + if err == m.ErrDashboardUpdateAccessDenied { + return ApiError(403, err.Error(), err) + } + if err == m.ErrDashboardContainsInvalidAlertData { return ApiError(500, "Invalid alert data. Cannot save dashboard", err) } if err != nil { - if err == m.ErrDashboardWithSameUIDExists { - return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()}) - } if err == m.ErrDashboardWithSameNameInFolderExists { return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()}) } @@ -281,8 +262,6 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { return ApiError(500, "Invalid alert data. Cannot save dashboard", err) } - dashboard.IsFolder = dash.IsFolder - c.TimeRequest(metrics.M_Api_Dashboard_Save) return Json(200, util.DynMap{ "status": "success", @@ -357,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) { func GetDashboardVersions(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + guardian := guardian.New(dashId, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -396,7 +375,7 @@ func GetDashboardVersions(c *middleware.Context) Response { func GetDashboardVersion(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + guardian := guardian.New(dashId, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -464,7 +443,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -498,19 +477,3 @@ func GetDashboardTags(c *middleware.Context) { c.JSON(200, query.Result) } - -func GetFoldersForSignedInUser(c *middleware.Context) Response { - title := c.Query("query") - query := m.GetFoldersForSignedInUserQuery{ - OrgId: c.OrgId, - SignedInUser: c.SignedInUser, - Title: title, - } - - err := bus.Dispatch(&query) - if err != nil { - return ApiError(500, "Failed to get folders from database", err) - } - - return Json(200, query.Result) -} diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go index b5d912d25f1..13b29db78e6 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_acl.go @@ -13,7 +13,12 @@ import ( func GetDashboardAclList(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") + if rsp != nil { + return rsp + } + + guardian := guardian.New(dashId, c.OrgId, c.SignedInUser) if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { return dashboardGuardianResponse(err) @@ -36,7 +41,12 @@ func GetDashboardAclList(c *middleware.Context) Response { func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { dashId := c.ParamsInt64(":dashboardId") - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") + if rsp != nil { + return rsp + } + + guardian := guardian.New(dashId, c.OrgId, c.SignedInUser) if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { return dashboardGuardianResponse(err) } @@ -74,28 +84,3 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom return ApiSuccess("Dashboard acl updated") } - -func DeleteDashboardAcl(c *middleware.Context) Response { - dashId := c.ParamsInt64(":dashboardId") - aclId := c.ParamsInt64(":aclId") - - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) - if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { - return dashboardGuardianResponse(err) - } - - if okToDelete, err := guardian.CheckPermissionBeforeRemove(m.PERMISSION_ADMIN, aclId); err != nil || !okToDelete { - if err != nil { - return ApiError(500, "Error while checking dashboard permissions", err) - } - - return ApiError(403, "Cannot remove own admin permission for a folder", nil) - } - - cmd := m.RemoveDashboardAclCommand{OrgId: c.OrgId, AclId: aclId} - if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete permission for user", err) - } - - return Json(200, "") -} diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index 467045e360a..d6b7e305daf 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_acl_test.go @@ -15,14 +15,22 @@ import ( func TestDashboardAclApiEndpoint(t *testing.T) { Convey("Given a dashboard acl", t, func() { mockResult := []*m.DashboardAclInfoDTO{ - {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, TeamId: 1, Permission: m.PERMISSION_VIEW}, - {Id: 5, OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, } dtoRes := transformDashboardAclsToDTOs(mockResult) + getDashboardQueryResult := m.NewDashboard("Dash") + var getDashboardNotFoundError error + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return getDashboardNotFoundError + }) + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { query.Result = dtoRes return nil @@ -60,11 +68,35 @@ func TestDashboardAclApiEndpoint(t *testing.T) { So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) }) }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { + getDashboardNotFoundError = m.ErrDashboardNotFound + sc.handlerFunc = GetDashboardAclList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + Convey("Should not be able to access ACL", func() { + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("Should not be able to update permissions for non-existing dashboard", func() { + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) { + getDashboardNotFoundError = m.ErrDashboardNotFound + CallPostAcl(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + }) }) Convey("When user is org editor and has admin permission in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) Convey("Should be able to access ACL", func() { sc.handlerFunc = GetDashboardAclList @@ -74,36 +106,6 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { - return nil - }) - - Convey("Should be able to delete permission", func() { - sc.handlerFunc = DeleteDashboardAcl - sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { - return nil - }) - - Convey("Should not be able to delete their own Admin permission", func() { - sc.handlerFunc = DeleteDashboardAcl - sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - Convey("Should not be able to downgrade their own Admin permission", func() { cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -112,7 +114,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { } postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) CallPostAcl(sc) So(sc.resp.Code, ShouldEqual, 403) @@ -128,34 +130,18 @@ func TestDashboardAclApiEndpoint(t *testing.T) { } postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) CallPostAcl(sc) So(sc.resp.Code, ShouldEqual, 200) }) }) - Convey("When user is 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) { - teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"}) - - bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { - return nil - }) - - Convey("Should be able to delete permission", func() { - sc.handlerFunc = DeleteDashboardAcl - sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - }) }) Convey("When user is org viewer and has edit permission in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) // Getting the permissions is an Admin permission Convey("Should not be able to get list of permissions from ACL", func() { @@ -165,21 +151,6 @@ func TestDashboardAclApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 403) }) }) - - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) - - bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { - return nil - }) - - Convey("Should be not be able to delete permission", func() { - sc.handlerFunc = DeleteDashboardAcl - sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) }) Convey("When user is org editor and not in the ACL", func() { @@ -192,20 +163,6 @@ func TestDashboardAclApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 403) }) }) - - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/user/1", "/api/dashboards/id/:dashboardsId/acl/user/:userId", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}) - bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { - return nil - }) - - Convey("Should be not be able to delete permission", func() { - sc.handlerFunc = DeleteDashboardAcl - sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) }) }) } @@ -215,7 +172,6 @@ func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardA for _, acl := range acls { dto := &m.DashboardAclInfoDTO{ - Id: acl.Id, OrgId: acl.OrgId, DashboardId: acl.DashboardId, Permission: acl.Permission, diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index be15fb41a66..046075fc622 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "testing" "github.com/grafana/grafana/pkg/api/dtos" @@ -9,28 +10,17 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" . "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 - -// This tests two main scenarios. If a user has access to execute an action on a dashboard: -// 1. and the dashboard is in a folder which does not have an acl -// 2. and the dashboard is in a folder which does have an acl +// This tests three main scenarios. +// If a user has access to execute an action on a dashboard: +// 1. and the dashboard is in a folder which does not have an acl +// 2. and the dashboard is in a folder which does have an acl +// 3. Post dashboard response tests func TestDashboardApiEndpoint(t *testing.T) { Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { @@ -71,14 +61,6 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - cmd := m.SaveDashboardCommand{ - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "folderId": fakeDash.FolderId, - "title": fakeDash.Title, - "id": fakeDash.Id, - }), - } - // This tests two scenarios: // 1. user is an org viewer // 2. user is an org editor @@ -141,11 +123,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) Convey("When user is an Org Editor", func() { @@ -206,32 +183,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 200) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboardShouldReturnSuccess(sc) - }) - - Convey("When saving a dashboard folder in another folder", func() { - bus.AddHandler("test", func(query *m.GetDashboardQuery) error { - query.Result = fakeDash - query.Result.IsFolder = true - return nil - }) - invalidCmd := m.SaveDashboardCommand{ - FolderId: fakeDash.FolderId, - IsFolder: true, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "folderId": fakeDash.FolderId, - "title": fakeDash.Title, - }), - } - Convey("Should return an error", func() { - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, invalidCmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 400) - }) - }) - }) }) }) @@ -274,15 +225,6 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - cmd := m.SaveDashboardCommand{ - FolderId: fakeDash.FolderId, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": fakeDash.Id, - "folderId": fakeDash.FolderId, - "title": fakeDash.Title, - }), - } - // This tests six scenarios: // 1. user is an org viewer AND has no permissions for this dashboard // 2. user is an org editor AND has no permissions for this dashboard @@ -347,11 +289,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) Convey("When user is an Org Editor and has no permissions for this dashboard", func() { @@ -410,18 +347,13 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) Convey("When user is an Org Viewer but has an edit permission", func() { role := m.ROLE_VIEWER mockResult := []*m.DashboardAclInfoDTO{ - {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT}, } bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { @@ -484,10 +416,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 200) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboardShouldReturnSuccess(sc) - }) }) Convey("When user is an Org Viewer and viewers can edit", func() { @@ -495,7 +423,7 @@ func TestDashboardApiEndpoint(t *testing.T) { setting.ViewersCanEdit = true mockResult := []*m.DashboardAclInfoDTO{ - {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, } bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { @@ -554,7 +482,7 @@ func TestDashboardApiEndpoint(t *testing.T) { role := m.ROLE_VIEWER mockResult := []*m.DashboardAclInfoDTO{ - {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN}, } bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { @@ -617,17 +545,13 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 200) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboardShouldReturnSuccess(sc) - }) }) Convey("When user is an Org Editor but has a view permission", func() { role := m.ROLE_EDITOR mockResult := []*m.DashboardAclInfoDTO{ - {Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, } bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { @@ -688,11 +612,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) }) @@ -726,6 +645,104 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) }) + + Convey("Post dashboard response tests", t, func() { + + // This tests that a valid request returns correct response + + Convey("Given a correct request for creating a dashboard", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + UserId: 5, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + Overwrite: true, + FolderId: 3, + IsFolder: false, + Message: "msg", + } + + mock := &dashboards.FakeDashboardService{ + SaveDashboardResult: &m.Dashboard{ + Id: 2, + Uid: "uid", + Title: "Dash", + Slug: "dash", + Version: 2, + }, + } + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", mock, cmd, func(sc *scenarioContext) { + CallPostDashboardShouldReturnSuccess(sc) + + Convey("It should call dashboard service with correct data", func() { + dto := mock.SavedDashboards[0] + So(dto.OrgId, ShouldEqual, cmd.OrgId) + So(dto.User.UserId, ShouldEqual, cmd.UserId) + So(dto.Dashboard.FolderId, ShouldEqual, 3) + So(dto.Dashboard.Title, ShouldEqual, "Dash") + So(dto.Overwrite, ShouldBeTrue) + So(dto.Message, ShouldEqual, "msg") + }) + + Convey("It should return correct response data", func() { + result := sc.ToJson() + So(result.Get("status").MustString(), ShouldEqual, "success") + So(result.Get("id").MustInt64(), ShouldEqual, 2) + So(result.Get("uid").MustString(), ShouldEqual, "uid") + So(result.Get("slug").MustString(), ShouldEqual, "dash") + So(result.Get("url").MustString(), ShouldEqual, "/d/uid/dash") + }) + }) + }) + + // This tests that invalid requests returns expected error responses + + Convey("Given incorrect requests for creating a dashboard", func() { + testCases := []struct { + SaveError error + ExpectedStatusCode int + }{ + {SaveError: m.ErrDashboardNotFound, ExpectedStatusCode: 404}, + {SaveError: m.ErrFolderNotFound, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardWithSameUIDExists, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardWithSameNameInFolderExists, ExpectedStatusCode: 412}, + {SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412}, + {SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardContainsInvalidAlertData, ExpectedStatusCode: 500}, + {SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500}, + {SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + {SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardFolderWithSameNameAsDashboard, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardWithSameNameAsFolder, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardFolderNameExists, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardUpdateAccessDenied, ExpectedStatusCode: 403}, + {SaveError: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {SaveError: m.UpdatePluginDashboardError{PluginId: "plug"}, ExpectedStatusCode: 412}, + } + + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "", + }), + } + + for _, tc := range testCases { + mock := &dashboards.FakeDashboardService{ + SaveDashboardError: tc.SaveError, + } + + postDashboardScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.SaveError.Error()), "/api/dashboards", "/api/dashboards", mock, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, tc.ExpectedStatusCode) + }) + } + }) + }) } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { @@ -780,19 +797,6 @@ func CallDeleteDashboardByUid(sc *scenarioContext) { } func CallPostDashboard(sc *scenarioContext) { - bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error { - return nil - }) - - bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { - cmd.Result = &m.Dashboard{Id: 2, Slug: "Dash", Version: 2} - return nil - }) - - bus.AddHandler("test", func(cmd *alerting.UpdateDashboardAlertsCommand) error { - return nil - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } @@ -800,33 +804,29 @@ func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) { CallPostDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) - result := sc.ToJson() - So(result.Get("status").MustString(), ShouldEqual, "success") - So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0) - So(result.Get("uid").MustString(), ShouldNotBeNil) - So(result.Get("slug").MustString(), ShouldNotBeNil) - So(result.Get("url").MustString(), ShouldNotBeNil) } -func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) { +func postDashboardScenario(desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, cmd m.SaveDashboardCommand, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() sc := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role + sc.context.SignedInUser = &m.SignedInUser{OrgId: cmd.OrgId, UserId: cmd.UserId} return PostDashboard(c, cmd) }) - fakeRepo = &fakeDashboardRepo{} - dashboards.SetRepository(fakeRepo) + origNewDashboardService := dashboards.NewService + dashboards.MockDashboardService(mock) sc.m.Post(routePattern, sc.defaultHandler) + defer func() { + dashboards.NewService = origNewDashboardService + }() + fn(sc) }) } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 57a15bd8db5..433b9f2bd66 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -46,26 +46,30 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { // GET /api/org/users func GetOrgUsersForCurrentOrg(c *middleware.Context) Response { - return getOrgUsersHelper(c.OrgId) + return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit")) } // GET /api/orgs/:orgId/users func GetOrgUsers(c *middleware.Context) Response { - return getOrgUsersHelper(c.ParamsInt64(":orgId")) + return getOrgUsersHelper(c.ParamsInt64(":orgId"), "", 0) } -func getOrgUsersHelper(orgId int64) Response { - query := m.GetOrgUsersQuery{OrgId: orgId} +func getOrgUsersHelper(orgId int64, query string, limit int) Response { + q := m.GetOrgUsersQuery{ + OrgId: orgId, + Query: query, + Limit: limit, + } - if err := bus.Dispatch(&query); err != nil { + if err := bus.Dispatch(&q); err != nil { return ApiError(500, "Failed to get account user", err) } - for _, user := range query.Result { + for _, user := range q.Result { user.AvatarUrl = dtos.GetGravatarUrl(user.Email) } - return Json(200, query.Result) + return Json(200, q.Result) } // PATCH /api/org/users/:userId diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 0483b624a30..e8c21541339 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -168,7 +168,7 @@ func ImportDashboard(c *middleware.Context, apiCmd dtos.ImportDashboardCommand) cmd := plugins.ImportDashboardCommand{ OrgId: c.OrgId, - UserId: c.UserId, + User: c.SignedInUser, PluginId: apiCmd.PluginId, Path: apiCmd.Path, Inputs: apiCmd.Inputs, diff --git a/pkg/api/search.go b/pkg/api/search.go index fee062a5599..f79385d83f8 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" ) @@ -15,11 +16,16 @@ func Search(c *middleware.Context) { starred := c.Query("starred") limit := c.QueryInt("limit") dashboardType := c.Query("type") + permission := models.PERMISSION_VIEW if limit == 0 { limit = 1000 } + if c.Query("permission") == "Edit" { + permission = models.PERMISSION_EDIT + } + dbids := make([]int64, 0) for _, id := range c.QueryStrings("dashboardIds") { dashboardId, err := strconv.ParseInt(id, 10, 64) @@ -46,6 +52,7 @@ func Search(c *middleware.Context) { DashboardIds: dbids, Type: dashboardType, FolderIds: folderIds, + Permission: permission, } err := bus.Dispatch(&searchQuery) diff --git a/pkg/api/team.go b/pkg/api/team.go index af537224d41..f11eca68b91 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -26,6 +26,7 @@ func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response { // PUT /api/teams/:teamId func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { + cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { @@ -39,7 +40,7 @@ func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { // DELETE /api/teams/:teamId func DeleteTeamById(c *middleware.Context) Response { - if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil { + if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil { if err == m.ErrTeamNotFound { return ApiError(404, "Failed to delete Team. ID not found", nil) } @@ -60,11 +61,11 @@ func SearchTeams(c *middleware.Context) Response { } query := m.SearchTeamsQuery{ + OrgId: c.OrgId, Query: c.Query("query"), Name: c.Query("name"), Page: page, Limit: perPage, - OrgId: c.OrgId, } if err := bus.Dispatch(&query); err != nil { @@ -83,7 +84,7 @@ func SearchTeams(c *middleware.Context) Response { // GET /api/teams/:teamId func GetTeamById(c *middleware.Context) Response { - query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")} + query := m.GetTeamByIdQuery{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")} if err := bus.Dispatch(&query); err != nil { if err == m.ErrTeamNotFound { diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 412e142edb7..8586ac04fdb 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -10,7 +10,7 @@ import ( // GET /api/teams/:teamId/members func GetTeamMembers(c *middleware.Context) Response { - query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")} + query := m.GetTeamMembersQuery{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId")} if err := bus.Dispatch(&query); err != nil { return ApiError(500, "Failed to get Team Members", err) @@ -29,9 +29,14 @@ func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response { 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) + if err == m.ErrTeamNotFound { + return ApiError(404, "Team not found", nil) } + + if err == m.ErrTeamMemberAlreadyAdded { + return ApiError(400, "User is already added to this team", nil) + } + return ApiError(500, "Failed to add Member to Team", err) } @@ -42,7 +47,15 @@ func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response { // DELETE /api/teams/:teamId/members/:userId func RemoveTeamMember(c *middleware.Context) Response { - if err := bus.Dispatch(&m.RemoveTeamMemberCommand{TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { + if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { + if err == m.ErrTeamNotFound { + return ApiError(404, "Team not found", nil) + } + + if err == m.ErrTeamMemberNotFound { + return ApiError(404, "Team member not found", nil) + } + return ApiError(500, "Failed to remove Member from Team", err) } return ApiSuccess("Team Member removed") diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index a1b249d9c81..f40bc9c081b 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -94,7 +94,7 @@ func InstallPlugin(pluginName, version string, c CommandLine) error { res, _ := s.ReadPlugin(pluginFolder, pluginName) for _, v := range res.Dependencies.Plugins { - InstallPlugin(v.Id, version, c) + InstallPlugin(v.Id, "", c) logger.Infof("Installed dependency: %v ✔\n", v.Id) } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 826287e12f3..65697a616ea 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -51,7 +51,8 @@ func notAuthorized(c *Context) { return } - c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/") + c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/", nil, false, true) + c.Redirect(setting.AppSubUrl + "/login") } diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 388acc15afc..a8bdf809637 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -115,11 +115,11 @@ func Recovery() macaron.Handler { c.Data["Title"] = "Server Error" c.Data["AppSubUrl"] = setting.AppSubUrl - if theErr, ok := err.(error); ok { - c.Data["Title"] = theErr.Error() - } - if setting.Env == setting.DEV { + if theErr, ok := err.(error); ok { + c.Data["Title"] = theErr.Error() + } + c.Data["ErrorMsg"] = string(stack) } diff --git a/pkg/models/alert.go b/pkg/models/alert.go index b378c5cf90f..88b49350b97 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -166,8 +166,9 @@ type GetAlertsQuery struct { DashboardId int64 PanelId int64 Limit int64 + User *SignedInUser - Result []*Alert + Result []*AlertListItemDTO } type GetAllAlertsQuery struct { @@ -187,6 +188,21 @@ type GetAlertStatesForDashboardQuery struct { Result []*AlertStateInfoDTO } +type AlertListItemDTO struct { + Id int64 `json:"id"` + DashboardId int64 `json:"dashboardId"` + DashboardUid string `json:"dashboardUid"` + DashboardSlug string `json:"dashboardSlug"` + PanelId int64 `json:"panelId"` + Name string `json:"name"` + State AlertStateType `json:"state"` + NewStateDate time.Time `json:"newStateDate"` + EvalDate time.Time `json:"evalDate"` + EvalData *simplejson.Json `json:"evalData"` + ExecutionError string `json:"executionError"` + Url string `json:"url"` +} + type AlertStateInfoDTO struct { Id int64 `json:"id"` DashboardId int64 `json:"dashboardId"` @@ -194,3 +210,17 @@ type AlertStateInfoDTO struct { State AlertStateType `json:"state"` NewStateDate time.Time `json:"newStateDate"` } + +// "Internal" commands + +type UpdateDashboardAlertsCommand struct { + UserId int64 + OrgId int64 + Dashboard *Dashboard +} + +type ValidateDashboardAlertsCommand struct { + UserId int64 + OrgId int64 + Dashboard *Dashboard +} diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index 933487650e3..202b519207d 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -44,7 +44,6 @@ type DashboardAcl struct { } type DashboardAclInfoDTO struct { - Id int64 `json:"id"` OrgId int64 `json:"-"` DashboardId int64 `json:"dashboardId"` @@ -75,21 +74,6 @@ type UpdateDashboardAclCommand struct { Items []*DashboardAcl } -type SetDashboardAclCommand struct { - DashboardId int64 - OrgId int64 - UserId int64 - TeamId int64 - Permission PermissionType - - Result DashboardAcl -} - -type RemoveDashboardAclCommand struct { - AclId int64 - OrgId int64 -} - // // QUERIES // diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 866d10850dc..5bf37136548 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -13,22 +13,26 @@ import ( // Typed errors var ( - ErrDashboardNotFound = errors.New("Dashboard not found") - ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") - ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") - ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") - ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") - ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") - ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") - ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") - ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") - ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") - ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") - ErrDashboardExistingCannotChangeToDashboard = errors.New("An existing folder cannot be changed to a dashboard") - ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") - ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") - ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") - RootFolderName = "General" + ErrDashboardNotFound = errors.New("Dashboard not found") + ErrFolderNotFound = errors.New("Folder not found") + ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") + ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") + ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") + ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") + ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") + ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") + ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") + ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") + ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") + ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") + ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") + ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") + ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") + ErrDashboardFolderNameExists = errors.New("A folder with that name already exists") + ErrDashboardUpdateAccessDenied = errors.New("Access denied to save dashboard") + ErrDashboardInvalidUid = errors.New("uid contains illegal characters") + ErrDashboardUidToLong = errors.New("uid to long. max 40 characters") + RootFolderName = "General" ) type UpdatePluginDashboardError struct { @@ -69,6 +73,30 @@ type Dashboard struct { Data *simplejson.Json } +func (d *Dashboard) SetId(id int64) { + d.Id = id + d.Data.Set("id", id) +} + +func (d *Dashboard) SetUid(uid string) { + d.Uid = uid + d.Data.Set("uid", uid) +} + +func (d *Dashboard) SetVersion(version int) { + d.Version = version + d.Data.Set("version", version) +} + +// GetDashboardIdForSavePermissionCheck return the dashboard id to be used for checking permission of dashboard +func (d *Dashboard) GetDashboardIdForSavePermissionCheck() int64 { + if d.Id == 0 { + return d.FolderId + } + + return d.Id +} + // NewDashboard creates a new dashboard func NewDashboard(title string) *Dashboard { dash := &Dashboard{} @@ -87,6 +115,7 @@ func NewDashboardFolder(title string) *Dashboard { folder.Data.Set("schemaVersion", 16) folder.Data.Set("editable", true) folder.Data.Set("hideControls", true) + folder.IsFolder = true return folder } @@ -219,11 +248,32 @@ type SaveDashboardCommand struct { Result *Dashboard } +type DashboardProvisioning struct { + Id int64 + DashboardId int64 + Name string + ExternalId string + Updated int64 +} + +type SaveProvisionedDashboardCommand struct { + DashboardCmd *SaveDashboardCommand + DashboardProvisioning *DashboardProvisioning + + Result *Dashboard +} + type DeleteDashboardCommand struct { Id int64 OrgId int64 } +type ValidateDashboardBeforeSaveCommand struct { + OrgId int64 + Dashboard *Dashboard + Overwrite bool +} + // // QUERIES // @@ -271,6 +321,12 @@ type GetDashboardSlugByIdQuery struct { Result string } +type GetProvisionedDashboardDataQuery struct { + Name string + + Result []*DashboardProvisioning +} + type GetDashboardsBySlugQuery struct { OrgId int64 Slug string @@ -278,18 +334,6 @@ type GetDashboardsBySlugQuery struct { Result []*Dashboard } -type GetFoldersForSignedInUserQuery struct { - OrgId int64 - SignedInUser *SignedInUser - Title string - Result []*DashboardFolder -} - -type DashboardFolder struct { - Id int64 `json:"id"` - Title string `json:"title"` -} - type DashboardPermissionForUser struct { DashboardId int64 `json:"dashboardId"` Permission PermissionType `json:"permission"` diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 9c1cb6fe9e2..d2b93c5c1d6 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -58,21 +58,22 @@ type DataSource struct { } var knownDatasourcePlugins map[string]bool = map[string]bool{ - DS_ES: true, - DS_GRAPHITE: true, - DS_INFLUXDB: true, - DS_INFLUXDB_08: true, - DS_KAIROSDB: true, - DS_CLOUDWATCH: true, - DS_PROMETHEUS: true, - DS_OPENTSDB: true, - DS_POSTGRES: true, - DS_MYSQL: true, - "opennms": true, - "druid": true, - "dalmatinerdb": true, - "gnocci": true, - "zabbix": true, + DS_ES: true, + DS_GRAPHITE: true, + DS_INFLUXDB: true, + DS_INFLUXDB_08: true, + DS_KAIROSDB: true, + DS_CLOUDWATCH: true, + DS_PROMETHEUS: true, + DS_OPENTSDB: true, + DS_POSTGRES: true, + DS_MYSQL: true, + "opennms": true, + "abhisant-druid-datasource": true, + "dalmatinerdb-datasource": true, + "gnocci": true, + "zabbix": true, + "alexanderzobnin-zabbix-datasource": true, "newrelic-app": true, "grafana-datadog-datasource": true, "grafana-simple-json": true, diff --git a/pkg/models/login_attempt.go b/pkg/models/login_attempt.go index e4391927702..6e0976bc506 100644 --- a/pkg/models/login_attempt.go +++ b/pkg/models/login_attempt.go @@ -8,7 +8,7 @@ type LoginAttempt struct { Id int64 Username string IpAddress string - Created time.Time + Created int64 } // --------------------- diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 9379625d458..ca32cc50060 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -95,7 +95,10 @@ type UpdateOrgUserCommand struct { // QUERIES type GetOrgUsersQuery struct { - OrgId int64 + OrgId int64 + Query string + Limit int + Result []*OrgUserDTO } diff --git a/pkg/models/team.go b/pkg/models/team.go index d2912f431b8..9c679a13394 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -7,8 +7,9 @@ import ( // Typed errors var ( - ErrTeamNotFound = errors.New("Team not found") - ErrTeamNameTaken = errors.New("Team name is taken") + ErrTeamNotFound = errors.New("Team not found") + ErrTeamNameTaken = errors.New("Team name is taken") + ErrTeamMemberNotFound = errors.New("Team member not found") ) // Team model @@ -37,18 +38,22 @@ type UpdateTeamCommand struct { Id int64 Name string Email string + OrgId int64 `json:"-"` } type DeleteTeamCommand struct { - Id int64 + OrgId int64 + Id int64 } type GetTeamByIdQuery struct { + OrgId int64 Id int64 Result *Team } type GetTeamsByUserQuery struct { + OrgId int64 UserId int64 `json:"userId"` Result []*Team `json:"teams"` } diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 9970678a1ae..19cf657292d 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -31,6 +31,7 @@ type AddTeamMemberCommand struct { } type RemoveTeamMemberCommand struct { + OrgId int64 `json:"-"` UserId int64 TeamId int64 } @@ -39,6 +40,7 @@ type RemoveTeamMemberCommand struct { // QUERIES type GetTeamMembersQuery struct { + OrgId int64 TeamId int64 Result []*TeamMemberDTO } diff --git a/pkg/plugins/dashboard_importer.go b/pkg/plugins/dashboard_importer.go index 9036b943b30..53012f3e817 100644 --- a/pkg/plugins/dashboard_importer.go +++ b/pkg/plugins/dashboard_importer.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" ) type ImportDashboardCommand struct { @@ -17,7 +18,7 @@ type ImportDashboardCommand struct { Overwrite bool OrgId int64 - UserId int64 + User *m.SignedInUser PluginId string Result *PluginDashboardInfoDTO } @@ -34,7 +35,7 @@ type DashboardInputMissingError struct { } func (e DashboardInputMissingError) Error() string { - return fmt.Sprintf("Dashbord input variable: %v missing from import command", e.VariableName) + return fmt.Sprintf("Dashboard input variable: %v missing from import command", e.VariableName) } func init() { @@ -66,23 +67,32 @@ func ImportDashboard(cmd *ImportDashboardCommand) error { saveCmd := m.SaveDashboardCommand{ Dashboard: generatedDash, OrgId: cmd.OrgId, - UserId: cmd.UserId, + UserId: cmd.User.UserId, Overwrite: cmd.Overwrite, PluginId: cmd.PluginId, FolderId: dashboard.FolderId, } - if err := bus.Dispatch(&saveCmd); err != nil { + dto := &dashboards.SaveDashboardDTO{ + OrgId: cmd.OrgId, + Dashboard: saveCmd.GetDashboardModel(), + Overwrite: saveCmd.Overwrite, + User: cmd.User, + } + + savedDash, err := dashboards.NewService().SaveDashboard(dto) + + if err != nil { return err } cmd.Result = &PluginDashboardInfoDTO{ PluginId: cmd.PluginId, - Title: dashboard.Title, + Title: savedDash.Title, Path: cmd.Path, - Revision: dashboard.Data.Get("revision").MustInt64(1), - ImportedUri: "db/" + saveCmd.Result.Slug, - ImportedUrl: saveCmd.Result.GetUrl(), + Revision: savedDash.Data.Get("revision").MustInt64(1), + ImportedUri: "db/" + savedDash.Slug, + ImportedUrl: savedDash.GetUrl(), ImportedRevision: dashboard.Data.Get("revision").MustInt64(1), Imported: true, } diff --git a/pkg/plugins/dashboard_importer_test.go b/pkg/plugins/dashboard_importer_test.go index a13dc8fe0a5..549b3bb4cf9 100644 --- a/pkg/plugins/dashboard_importer_test.go +++ b/pkg/plugins/dashboard_importer_test.go @@ -5,9 +5,9 @@ import ( "io/ioutil" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" "gopkg.in/ini.v1" @@ -15,19 +15,15 @@ import ( func TestDashboardImport(t *testing.T) { pluginScenario("When importing a plugin dashboard", t, func() { - var importedDash *m.Dashboard - - bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { - importedDash = cmd.GetDashboardModel() - cmd.Result = importedDash - return nil - }) + origNewDashboardService := dashboards.NewService + mock := &dashboards.FakeDashboardService{} + dashboards.MockDashboardService(mock) cmd := ImportDashboardCommand{ PluginId: "test-app", Path: "dashboards/connections.json", OrgId: 1, - UserId: 1, + User: &m.SignedInUser{UserId: 1, OrgRole: m.ROLE_ADMIN}, Inputs: []ImportDashboardInput{ {Name: "*", Type: "datasource", Value: "graphite"}, }, @@ -37,18 +33,22 @@ func TestDashboardImport(t *testing.T) { So(err, ShouldBeNil) Convey("should install dashboard", func() { - So(importedDash, ShouldNotBeNil) + So(cmd.Result, ShouldNotBeNil) - resultStr, _ := importedDash.Data.EncodePretty() + resultStr, _ := mock.SavedDashboards[0].Dashboard.Data.EncodePretty() expectedBytes, _ := ioutil.ReadFile("../../tests/test-app/dashboards/connections_result.json") expectedJson, _ := simplejson.NewJson(expectedBytes) expectedStr, _ := expectedJson.EncodePretty() So(string(resultStr), ShouldEqual, string(expectedStr)) - panel := importedDash.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0) + panel := mock.SavedDashboards[0].Dashboard.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0) So(panel.Get("datasource").MustString(), ShouldEqual, "graphite") }) + + Reset(func() { + dashboards.NewService = origNewDashboardService + }) }) Convey("When evaling dashboard template", t, func() { @@ -84,7 +84,6 @@ func TestDashboardImport(t *testing.T) { }) }) - } func pluginScenario(desc string, t *testing.T, fn func()) { diff --git a/pkg/plugins/dashboards_updater.go b/pkg/plugins/dashboards_updater.go index 4c40e536d14..835e8873810 100644 --- a/pkg/plugins/dashboards_updater.go +++ b/pkg/plugins/dashboards_updater.go @@ -47,7 +47,7 @@ func autoUpdateAppDashboard(pluginDashInfo *PluginDashboardInfoDTO, orgId int64) PluginId: pluginDashInfo.PluginId, Overwrite: true, Dashboard: dash.Data, - UserId: 0, + User: &m.SignedInUser{UserId: 0, OrgRole: m.ROLE_ADMIN}, Path: pluginDashInfo.Path, } diff --git a/pkg/services/alerting/commands.go b/pkg/services/alerting/commands.go index 62671a559fa..2c145614751 100644 --- a/pkg/services/alerting/commands.go +++ b/pkg/services/alerting/commands.go @@ -5,24 +5,12 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -type UpdateDashboardAlertsCommand struct { - UserId int64 - OrgId int64 - Dashboard *m.Dashboard -} - -type ValidateDashboardAlertsCommand struct { - UserId int64 - OrgId int64 - Dashboard *m.Dashboard -} - func init() { bus.AddHandler("alerting", updateDashboardAlerts) bus.AddHandler("alerting", validateDashboardAlerts) } -func validateDashboardAlerts(cmd *ValidateDashboardAlertsCommand) error { +func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error { extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) if _, err := extractor.GetAlerts(); err != nil { @@ -32,7 +20,7 @@ func validateDashboardAlerts(cmd *ValidateDashboardAlertsCommand) error { return nil } -func updateDashboardAlerts(cmd *UpdateDashboardAlertsCommand) error { +func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error { saveAlerts := m.SaveAlertsCommand{ OrgId: cmd.OrgId, UserId: cmd.UserId, diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go new file mode 100644 index 00000000000..baf8131f8fd --- /dev/null +++ b/pkg/services/dashboards/dashboard_service.go @@ -0,0 +1,232 @@ +package dashboards + +import ( + "strings" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/util" +) + +// DashboardService service for operating on dashboards +type DashboardService interface { + SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) +} + +// DashboardProvisioningService service for operating on provisioned dashboards +type DashboardProvisioningService interface { + SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) + SaveFolderForProvisionedDashboards(*SaveDashboardDTO) (*models.Dashboard, error) + GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) +} + +// NewService factory for creating a new dashboard service +var NewService = func() DashboardService { + return &dashboardServiceImpl{} +} + +// NewProvisioningService factory for creating a new dashboard provisioning service +var NewProvisioningService = func() DashboardProvisioningService { + return &dashboardServiceImpl{} +} + +type SaveDashboardDTO struct { + OrgId int64 + UpdatedAt time.Time + User *models.SignedInUser + Message string + Overwrite bool + Dashboard *models.Dashboard +} + +type dashboardServiceImpl struct{} + +func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + cmd := &models.GetProvisionedDashboardDataQuery{Name: name} + err := bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) { + dash := dto.Dashboard + + dash.Title = strings.TrimSpace(dash.Title) + dash.Data.Set("title", dash.Title) + dash.SetUid(strings.TrimSpace(dash.Uid)) + + if dash.Title == "" { + return nil, models.ErrDashboardTitleEmpty + } + + if dash.IsFolder && dash.FolderId > 0 { + return nil, models.ErrDashboardFolderCannotHaveParent + } + + if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(models.RootFolderName) { + return nil, models.ErrDashboardFolderNameExists + } + + if !util.IsValidShortUid(dash.Uid) { + return nil, models.ErrDashboardInvalidUid + } else if len(dash.Uid) > 40 { + return nil, models.ErrDashboardUidToLong + } + + validateAlertsCmd := models.ValidateDashboardAlertsCommand{ + OrgId: dto.OrgId, + Dashboard: dash, + } + + if err := bus.Dispatch(&validateAlertsCmd); err != nil { + return nil, models.ErrDashboardContainsInvalidAlertData + } + + validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{ + OrgId: dto.OrgId, + Dashboard: dash, + Overwrite: dto.Overwrite, + } + + if err := bus.Dispatch(&validateBeforeSaveCmd); err != nil { + return nil, err + } + + guard := guardian.New(dash.GetDashboardIdForSavePermissionCheck(), dto.OrgId, dto.User) + if canSave, err := guard.CanSave(); err != nil || !canSave { + if err != nil { + return nil, err + } + return nil, models.ErrDashboardUpdateAccessDenied + } + + cmd := &models.SaveDashboardCommand{ + Dashboard: dash.Data, + Message: dto.Message, + OrgId: dto.OrgId, + Overwrite: dto.Overwrite, + UserId: dto.User.UserId, + FolderId: dash.FolderId, + IsFolder: dash.IsFolder, + PluginId: dash.PluginId, + } + + if !dto.UpdatedAt.IsZero() { + cmd.UpdatedAt = dto.UpdatedAt + } + + return cmd, nil +} + +func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error { + alertCmd := models.UpdateDashboardAlertsCommand{ + OrgId: dto.OrgId, + UserId: dto.User.UserId, + Dashboard: cmd.Result, + } + + if err := bus.Dispatch(&alertCmd); err != nil { + return models.ErrDashboardFailedToUpdateAlertData + } + + return nil +} + +func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + dto.User = &models.SignedInUser{ + UserId: 0, + OrgRole: models.ROLE_ADMIN, + } + cmd, err := dr.buildSaveDashboardCommand(dto) + if err != nil { + return nil, err + } + + saveCmd := &models.SaveProvisionedDashboardCommand{ + DashboardCmd: cmd, + DashboardProvisioning: provisioning, + } + + // dashboard + err = bus.Dispatch(saveCmd) + if err != nil { + return nil, err + } + + //alerts + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDashboardDTO) (*models.Dashboard, error) { + dto.User = &models.SignedInUser{ + UserId: 0, + OrgRole: models.ROLE_ADMIN, + } + cmd, err := dr.buildSaveDashboardCommand(dto) + if err != nil { + return nil, err + } + + err = bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + cmd, err := dr.buildSaveDashboardCommand(dto) + if err != nil { + return nil, err + } + + err = bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +type FakeDashboardService struct { + SaveDashboardResult *models.Dashboard + SaveDashboardError error + SavedDashboards []*SaveDashboardDTO +} + +func (s *FakeDashboardService) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + s.SavedDashboards = append(s.SavedDashboards, dto) + + if s.SaveDashboardResult == nil && s.SaveDashboardError == nil { + s.SaveDashboardResult = dto.Dashboard + } + + return s.SaveDashboardResult, s.SaveDashboardError +} + +func MockDashboardService(mock *FakeDashboardService) { + NewService = func() DashboardService { + return mock + } +} diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go new file mode 100644 index 00000000000..4a7dba762f6 --- /dev/null +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -0,0 +1,144 @@ +package dashboards + +import ( + "errors" + "testing" + + "github.com/grafana/grafana/pkg/services/guardian" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardService(t *testing.T) { + Convey("Dashboard service tests", t, func() { + service := dashboardServiceImpl{} + + origNewDashboardGuardian := guardian.New + mockDashboardGuardian(&fakeDashboardGuardian{canSave: true}) + + Convey("Save dashboard validation", func() { + dto := &SaveDashboardDTO{} + + Convey("When saving a dashboard with empty title it should return error", func() { + titles := []string{"", " ", " \t "} + + for _, title := range titles { + dto.Dashboard = models.NewDashboard(title) + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardTitleEmpty) + } + }) + + Convey("Should return validation error if it's a folder and have a folder id", func() { + dto.Dashboard = models.NewDashboardFolder("Folder") + dto.Dashboard.FolderId = 1 + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardFolderCannotHaveParent) + }) + + Convey("Should return validation error if folder is named General", func() { + dto.Dashboard = models.NewDashboardFolder("General") + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardFolderNameExists) + }) + + Convey("When saving a dashboard should validate uid", func() { + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return nil + }) + + testCases := []struct { + Uid string + Error error + }{ + {Uid: "", Error: nil}, + {Uid: " ", Error: nil}, + {Uid: " \t ", Error: nil}, + {Uid: "asdf90_-", Error: nil}, + {Uid: "asdf/90", Error: models.ErrDashboardInvalidUid}, + {Uid: " asdfghjklqwertyuiopzxcvbnmasdfghjklqwer ", Error: nil}, + {Uid: "asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm", Error: models.ErrDashboardUidToLong}, + } + + for _, tc := range testCases { + dto.Dashboard = models.NewDashboard("title") + dto.Dashboard.SetUid(tc.Uid) + dto.User = &models.SignedInUser{} + + _, err := service.buildSaveDashboardCommand(dto) + So(err, ShouldEqual, tc.Error) + } + }) + + Convey("Should return validation error if alert data is invalid", func() { + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return errors.New("error") + }) + + dto.Dashboard = models.NewDashboard("Dash") + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData) + }) + }) + + Reset(func() { + guardian.New = origNewDashboardGuardian + }) + }) +} + +func mockDashboardGuardian(mock *fakeDashboardGuardian) { + guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian { + mock.orgId = orgId + mock.dashId = dashId + mock.user = user + return mock + } +} + +type fakeDashboardGuardian struct { + dashId int64 + orgId int64 + user *models.SignedInUser + canSave bool + canEdit bool + canView bool + canAdmin bool + hasPermission bool + checkPermissionBeforeUpdate bool +} + +func (g *fakeDashboardGuardian) CanSave() (bool, error) { + return g.canSave, nil +} + +func (g *fakeDashboardGuardian) CanEdit() (bool, error) { + return g.canEdit, nil +} + +func (g *fakeDashboardGuardian) CanView() (bool, error) { + return g.canView, nil +} + +func (g *fakeDashboardGuardian) CanAdmin() (bool, error) { + return g.canAdmin, nil +} + +func (g *fakeDashboardGuardian) HasPermission(permission models.PermissionType) (bool, error) { + return g.hasPermission, nil +} + +func (g *fakeDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) { + return g.checkPermissionBeforeUpdate, nil +} + +func (g *fakeDashboardGuardian) GetAcl() ([]*models.DashboardAclInfoDTO, error) { + return nil, nil +} diff --git a/pkg/services/dashboards/dashboards.go b/pkg/services/dashboards/dashboards.go deleted file mode 100644 index 4bdba59b18e..00000000000 --- a/pkg/services/dashboards/dashboards.go +++ /dev/null @@ -1,82 +0,0 @@ -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 { - OrgId int64 - 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, - FolderId: dashboard.FolderId, - IsFolder: dashboard.IsFolder, - } - - if !json.UpdatedAt.IsZero() { - cmd.UpdatedAt = json.UpdatedAt - } - - err := bus.Dispatch(&cmd) - if err != nil { - return nil, err - } - - alertCmd := alerting.UpdateDashboardAlertsCommand{ - OrgId: json.OrgId, - UserId: json.UserId, - Dashboard: cmd.Result, - } - - if err := bus.Dispatch(&alertCmd); err != nil { - return nil, models.ErrDashboardFailedToUpdateAlertData - } - - return cmd.Result, nil -} diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index f4056841c33..98db5449182 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -7,7 +7,18 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -type DashboardGuardian struct { +// DashboardGuardian to be used for guard against operations without access on dashboard and acl +type DashboardGuardian interface { + CanSave() (bool, error) + CanEdit() (bool, error) + CanView() (bool, error) + CanAdmin() (bool, error) + HasPermission(permission m.PermissionType) (bool, error) + CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) + GetAcl() ([]*m.DashboardAclInfoDTO, error) +} + +type dashboardGuardianImpl struct { user *m.SignedInUser dashId int64 orgId int64 @@ -16,8 +27,9 @@ type DashboardGuardian struct { log log.Logger } -func NewDashboardGuardian(dashId int64, orgId int64, user *m.SignedInUser) *DashboardGuardian { - return &DashboardGuardian{ +// New factory for creating a new dashboard guardian instance +var New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian { + return &dashboardGuardianImpl{ user: user, dashId: dashId, orgId: orgId, @@ -25,11 +37,11 @@ func NewDashboardGuardian(dashId int64, orgId int64, user *m.SignedInUser) *Dash } } -func (g *DashboardGuardian) CanSave() (bool, error) { +func (g *dashboardGuardianImpl) CanSave() (bool, error) { return g.HasPermission(m.PERMISSION_EDIT) } -func (g *DashboardGuardian) CanEdit() (bool, error) { +func (g *dashboardGuardianImpl) CanEdit() (bool, error) { if setting.ViewersCanEdit { return g.HasPermission(m.PERMISSION_VIEW) } @@ -37,15 +49,15 @@ func (g *DashboardGuardian) CanEdit() (bool, error) { return g.HasPermission(m.PERMISSION_EDIT) } -func (g *DashboardGuardian) CanView() (bool, error) { +func (g *dashboardGuardianImpl) CanView() (bool, error) { return g.HasPermission(m.PERMISSION_VIEW) } -func (g *DashboardGuardian) CanAdmin() (bool, error) { +func (g *dashboardGuardianImpl) CanAdmin() (bool, error) { return g.HasPermission(m.PERMISSION_ADMIN) } -func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) { +func (g *dashboardGuardianImpl) HasPermission(permission m.PermissionType) (bool, error) { if g.user.OrgRole == m.ROLE_ADMIN { return true, nil } @@ -58,7 +70,7 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er return g.checkAcl(permission, acl) } -func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) { +func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) { orgRole := g.user.OrgRole teamAclItems := []*m.DashboardAclInfoDTO{} @@ -106,27 +118,7 @@ func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.Dashb return false, nil } -func (g *DashboardGuardian) CheckPermissionBeforeRemove(permission m.PermissionType, aclIdToRemove int64) (bool, error) { - if g.user.OrgRole == m.ROLE_ADMIN { - return true, nil - } - - acl, err := g.GetAcl() - if err != nil { - return false, err - } - - for i, p := range acl { - if p.Id == aclIdToRemove { - acl = append(acl[:i], acl[i+1:]...) - break - } - } - - return g.checkAcl(permission, acl) -} - -func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { +func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { if g.user.OrgRole == m.ROLE_ADMIN { return true, nil } @@ -141,7 +133,7 @@ func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionT } // GetAcl returns dashboard acl -func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { +func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) { if g.acl != nil { return g.acl, nil } @@ -155,12 +147,12 @@ func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { return g.acl, nil } -func (g *DashboardGuardian) getTeams() ([]*m.Team, error) { +func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) { if g.groups != nil { return g.groups, nil } - query := m.GetTeamsByUserQuery{UserId: g.user.UserId} + query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId} err := bus.Dispatch(&query) g.groups = query.Result diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index ab9e85f4d38..9030ba609b9 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -2,6 +2,7 @@ package dashboards import ( "io/ioutil" + "os" "path/filepath" "strings" @@ -14,11 +15,48 @@ type configReader struct { log log.Logger } +func (cr *configReader) parseConfigs(file os.FileInfo) ([]*DashboardsAsConfig, error) { + filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name())) + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + apiVersion := &ConfigVersion{ApiVersion: 0} + yaml.Unmarshal(yamlFile, &apiVersion) + + if apiVersion.ApiVersion > 0 { + + v1 := &DashboardAsConfigV1{} + err := yaml.Unmarshal(yamlFile, &v1) + if err != nil { + return nil, err + } + + if v1 != nil { + return v1.mapToDashboardAsConfig(), nil + } + + } else { + var v0 []*DashboardsAsConfigV0 + err := yaml.Unmarshal(yamlFile, &v0) + if err != nil { + return nil, err + } + + if v0 != nil { + cr.log.Warn("[Deprecated] the dashboard provisioning config is outdated. please upgrade", "filename", filename) + return mapV0ToDashboardAsConfig(v0), nil + } + } + + return []*DashboardsAsConfig{}, nil +} + func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { var dashboards []*DashboardsAsConfig files, err := ioutil.ReadDir(cr.path) - if err != nil { cr.log.Error("cant read dashboard provisioning files from directory", "path", cr.path) return dashboards, nil @@ -29,19 +67,14 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { continue } - filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name())) - yamlFile, err := ioutil.ReadFile(filename) + parsedDashboards, err := cr.parseConfigs(file) if err != nil { - return nil, err + } - var dashCfg []*DashboardsAsConfig - err = yaml.Unmarshal(yamlFile, &dashCfg) - if err != nil { - return nil, err + if len(parsedDashboards) > 0 { + dashboards = append(dashboards, parsedDashboards...) } - - dashboards = append(dashboards, dashCfg...) } for i := range dashboards { diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index bb960a72094..ecbf6435c36 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -9,48 +9,33 @@ import ( var ( simpleDashboardConfig string = "./test-configs/dashboards-from-disk" + oldVersion string = "./test-configs/version-0" brokenConfigs string = "./test-configs/broken-configs" ) func TestDashboardsAsConfig(t *testing.T) { Convey("Dashboards as configuration", t, func() { + logger := log.New("test-logger") - Convey("Can read config file", func() { - - cfgProvider := configReader{path: simpleDashboardConfig, log: log.New("test-logger")} + Convey("Can read config file version 1 format", func() { + cfgProvider := configReader{path: simpleDashboardConfig, log: logger} cfg, err := cfgProvider.readConfig() - if err != nil { - t.Fatalf("readConfig return an error %v", err) - } + So(err, ShouldBeNil) - So(len(cfg), ShouldEqual, 2) + validateDashboardAsConfig(cfg) + }) - ds := cfg[0] + Convey("Can read config file in version 0 format", func() { + cfgProvider := configReader{path: oldVersion, log: logger} + cfg, err := cfgProvider.readConfig() + So(err, ShouldBeNil) - 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["path"], 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["path"], ShouldEqual, "/var/lib/grafana/dashboards") + validateDashboardAsConfig(cfg) }) Convey("Should skip invalid path", func() { - cfgProvider := configReader{path: "/invalid-directory", log: log.New("test-logger")} + cfgProvider := configReader{path: "/invalid-directory", log: logger} cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -61,7 +46,7 @@ func TestDashboardsAsConfig(t *testing.T) { Convey("Should skip broken config files", func() { - cfgProvider := configReader{path: brokenConfigs, log: log.New("test-logger")} + cfgProvider := configReader{path: brokenConfigs, log: logger} cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -71,3 +56,26 @@ func TestDashboardsAsConfig(t *testing.T) { }) }) } +func validateDashboardAsConfig(cfg []*DashboardsAsConfig) { + 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["path"], ShouldEqual, "/var/lib/grafana/dashboards") + So(ds.DisableDeletion, ShouldBeTrue) + + 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["path"], ShouldEqual, "/var/lib/grafana/dashboards") + So(ds2.DisableDeletion, ShouldBeFalse) +} diff --git a/pkg/services/provisioning/dashboards/dashboard_cache.go b/pkg/services/provisioning/dashboards/dashboard_cache.go deleted file mode 100644 index da6b7e8a5e8..00000000000 --- a/pkg/services/provisioning/dashboards/dashboard_cache.go +++ /dev/null @@ -1,33 +0,0 @@ -package dashboards - -import ( - "github.com/grafana/grafana/pkg/services/dashboards" - gocache "github.com/patrickmn/go-cache" - "time" -) - -type dashboardCache struct { - internalCache *gocache.Cache -} - -func NewDashboardCache() *dashboardCache { - return &dashboardCache{internalCache: gocache.New(5*time.Minute, 30*time.Minute)} -} - -func (fr *dashboardCache) addDashboardCache(key string, json *dashboards.SaveDashboardItem) { - fr.internalCache.Add(key, json, time.Minute*10) -} - -func (fr *dashboardCache) getCache(key string) (*dashboards.SaveDashboardItem, bool) { - obj, exist := fr.internalCache.Get(key) - if !exist { - return nil, exist - } - - dash, ok := obj.(*dashboards.SaveDashboardItem) - if !ok { - return nil, ok - } - - return dash, ok -} diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index fbe1a03e287..d3e9892c8f5 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -25,12 +25,10 @@ var ( ) type fileReader struct { - Cfg *DashboardsAsConfig - Path string - log log.Logger - dashboardRepo dashboards.Repository - cache *dashboardCache - createWalk func(fr *fileReader, folderId int64) filepath.WalkFunc + Cfg *DashboardsAsConfig + Path string + log log.Logger + dashboardService dashboards.DashboardProvisioningService } func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) { @@ -50,28 +48,26 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade } return &fileReader{ - Cfg: cfg, - Path: path, - log: log, - dashboardRepo: dashboards.GetRepository(), - cache: NewDashboardCache(), - createWalk: createWalkFn, + Cfg: cfg, + Path: path, + log: log, + dashboardService: dashboards.NewProvisioningService(), }, nil } func (fr *fileReader) ReadAndListen(ctx context.Context) error { - ticker := time.NewTicker(checkDiskForChangesInterval) - if err := fr.startWalkingDisk(); err != nil { fr.log.Error("failed to search for dashboards", "error", err) } + ticker := time.NewTicker(checkDiskForChangesInterval) + running := false for { select { case <-ticker.C: - if !running { // avoid walking the filesystem in parallel. incase fs is very slow. + if !running { // avoid walking the filesystem in parallel. in-case fs is very slow. running = true go func() { if err := fr.startWalkingDisk(); err != nil { @@ -93,15 +89,116 @@ func (fr *fileReader) startWalkingDisk() error { } } - folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardRepo) + folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardService) if err != nil && err != ErrFolderNameMissing { return err } - return filepath.Walk(fr.Path, fr.createWalk(fr, folderId)) + provisionedDashboardRefs, err := getProvisionedDashboardByPath(fr.dashboardService, fr.Cfg.Name) + if err != nil { + return err + } + + filesFoundOnDisk := map[string]os.FileInfo{} + err = filepath.Walk(fr.Path, createWalkFn(filesFoundOnDisk)) + if err != nil { + return err + } + + fr.deleteDashboardIfFileIsMissing(provisionedDashboardRefs, filesFoundOnDisk) + + sanityChecker := newProvisioningSanityChecker(fr.Cfg.Name) + + // save dashboards based on json files + for path, fileInfo := range filesFoundOnDisk { + provisioningMetadata, err := fr.saveDashboard(path, folderId, fileInfo, provisionedDashboardRefs) + sanityChecker.track(provisioningMetadata) + if err != nil { + fr.log.Error("failed to save dashboard", "error", err) + } + } + sanityChecker.logWarnings(fr.log) + + return nil +} +func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) { + if fr.Cfg.DisableDeletion { + return + } + + // find dashboards to delete since json file is missing + var dashboardToDelete []int64 + for path, provisioningData := range provisionedDashboardRefs { + _, existsOnDisk := filesFoundOnDisk[path] + if !existsOnDisk { + dashboardToDelete = append(dashboardToDelete, provisioningData.DashboardId) + } + } + // delete dashboard that are missing json file + for _, dashboardId := range dashboardToDelete { + fr.log.Debug("deleting provisioned dashboard. missing on disk", "id", dashboardId) + cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId} + err := bus.Dispatch(cmd) + if err != nil { + fr.log.Error("failed to delete dashboard", "id", cmd.Id) + } + } } -func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (int64, error) { +func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.FileInfo, provisionedDashboardRefs map[string]*models.DashboardProvisioning) (provisioningMetadata, error) { + provisioningMetadata := provisioningMetadata{} + resolvedFileInfo, err := resolveSymlink(fileInfo, path) + if err != nil { + return provisioningMetadata, err + } + + provisionedData, alreadyProvisioned := provisionedDashboardRefs[path] + upToDate := alreadyProvisioned && provisionedData.Updated == resolvedFileInfo.ModTime().Unix() + + dash, err := fr.readDashboardFromFile(path, resolvedFileInfo.ModTime(), folderId) + if err != nil { + fr.log.Error("failed to load dashboard from ", "file", path, "error", err) + return provisioningMetadata, nil + } + + // keeps track of what uid's and title's we have already provisioned + provisioningMetadata.uid = dash.Dashboard.Uid + provisioningMetadata.title = dash.Dashboard.Title + + if upToDate { + return provisioningMetadata, nil + } + + if dash.Dashboard.Id != 0 { + fr.log.Error("provisioned dashboard json files cannot contain id") + return provisioningMetadata, nil + } + + if alreadyProvisioned { + dash.Dashboard.SetId(provisionedData.DashboardId) + } + + fr.log.Debug("saving new dashboard", "file", path) + dp := &models.DashboardProvisioning{ExternalId: path, Name: fr.Cfg.Name, Updated: resolvedFileInfo.ModTime().Unix()} + _, err = fr.dashboardService.SaveProvisionedDashboard(dash, dp) + return provisioningMetadata, err +} + +func getProvisionedDashboardByPath(service dashboards.DashboardProvisioningService, name string) (map[string]*models.DashboardProvisioning, error) { + arr, err := service.GetProvisionedDashboardData(name) + if err != nil { + return nil, err + } + + byPath := map[string]*models.DashboardProvisioning{} + for _, pd := range arr { + byPath[pd.ExternalId] = pd + } + + return byPath, nil +} + +func getOrCreateFolderId(cfg *DashboardsAsConfig, service dashboards.DashboardProvisioningService) (int64, error) { if cfg.Folder == "" { return 0, ErrFolderNameMissing } @@ -115,12 +212,12 @@ func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (i // dashboard folder not found. create one. if err == models.ErrDashboardNotFound { - dash := &dashboards.SaveDashboardItem{} - dash.Dashboard = models.NewDashboard(cfg.Folder) + dash := &dashboards.SaveDashboardDTO{} + dash.Dashboard = models.NewDashboardFolder(cfg.Folder) dash.Dashboard.IsFolder = true dash.Overwrite = true dash.OrgId = cfg.OrgId - dbDash, err := repo.SaveDashboard(dash) + dbDash, err := service.SaveFolderForProvisionedDashboards(dash) if err != nil { return 0, err } @@ -129,83 +226,59 @@ func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (i } if !cmd.Result.IsFolder { - return 0, fmt.Errorf("Got invalid response. Expected folder, found dashboard") + return 0, fmt.Errorf("got invalid response. expected folder, found dashboard") } return cmd.Result.Id, nil } -func createWalkFn(fr *fileReader, folderId int64) filepath.WalkFunc { +func resolveSymlink(fileinfo os.FileInfo, path string) (os.FileInfo, error) { + checkFilepath, err := filepath.EvalSymlinks(path) + if path != checkFilepath { + path = checkFilepath + fi, err := os.Lstat(checkFilepath) + if err != nil { + return nil, err + } + + return fi, nil + } + + return fileinfo, err +} + +func createWalkFn(filesOnDisk map[string]os.FileInfo) filepath.WalkFunc { return func(path string, fileInfo os.FileInfo, err error) error { if err != nil { return err } - if fileInfo.IsDir() { - if strings.HasPrefix(fileInfo.Name(), ".") { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(fileInfo.Name(), ".json") { - return nil - } - - checkFilepath, err := filepath.EvalSymlinks(path) - - if path != checkFilepath { - path = checkFilepath - fi, err := os.Lstat(checkFilepath) - if err != nil { - return err - } - fileInfo = fi - } - - cachedDashboard, exist := fr.cache.getCache(path) - if exist && cachedDashboard.UpdatedAt == fileInfo.ModTime() { - return nil - } - - dash, err := fr.readDashboardFromFile(path, folderId) - if err != nil { - fr.log.Error("failed to load dashboard from ", "file", path, "error", err) - return nil - } - - if dash.Dashboard.Id != 0 { - fr.log.Error("Cannot provision dashboard. Please remove the id property from the json file") - return nil - } - - cmd := &models.GetDashboardQuery{Slug: dash.Dashboard.Slug} - err = bus.Dispatch(cmd) - - // if we don't have the dashboard in the db, save it! - if err == models.ErrDashboardNotFound { - fr.log.Debug("saving new dashboard", "file", path) - _, err = fr.dashboardRepo.SaveDashboard(dash) + isValid, err := validateWalkablePath(fileInfo) + if !isValid { return err } - if err != nil { - fr.log.Error("failed to query for dashboard", "slug", dash.Dashboard.Slug, "error", err) - return nil - } - - // break if db version is newer then fil version - if cmd.Result.Updated.Unix() >= fileInfo.ModTime().Unix() { - return nil - } - - fr.log.Debug("loading dashboard from disk into database.", "file", path) - _, err = fr.dashboardRepo.SaveDashboard(dash) - - return err + filesOnDisk[path] = fileInfo + return nil } } -func (fr *fileReader) readDashboardFromFile(path string, folderId int64) (*dashboards.SaveDashboardItem, error) { +func validateWalkablePath(fileInfo os.FileInfo) (bool, error) { + if fileInfo.IsDir() { + if strings.HasPrefix(fileInfo.Name(), ".") { + return false, filepath.SkipDir + } + return false, nil + } + + if !strings.HasSuffix(fileInfo.Name(), ".json") { + return false, nil + } + + return true, nil +} + +func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time, folderId int64) (*dashboards.SaveDashboardDTO, error) { reader, err := os.Open(path) if err != nil { return nil, err @@ -217,17 +290,53 @@ func (fr *fileReader) readDashboardFromFile(path string, folderId int64) (*dashb return nil, err } - stat, err := os.Stat(path) + dash, err := createDashboardJson(data, lastModified, fr.Cfg, folderId) if err != nil { return nil, err } - dash, err := createDashboardJson(data, stat.ModTime(), fr.Cfg, folderId) - if err != nil { - return nil, err - } - - fr.cache.addDashboardCache(path, dash) - return dash, nil } + +type provisioningMetadata struct { + uid string + title string +} + +func newProvisioningSanityChecker(provisioningProvider string) provisioningSanityChecker { + return provisioningSanityChecker{ + provisioningProvider: provisioningProvider, + uidUsage: map[string]uint8{}, + titleUsage: map[string]uint8{}} +} + +type provisioningSanityChecker struct { + provisioningProvider string + uidUsage map[string]uint8 + titleUsage map[string]uint8 +} + +func (checker provisioningSanityChecker) track(pm provisioningMetadata) { + if len(pm.uid) > 0 { + checker.uidUsage[pm.uid] += 1 + } + if len(pm.title) > 0 { + checker.titleUsage[pm.title] += 1 + } + +} + +func (checker provisioningSanityChecker) logWarnings(log log.Logger) { + for uid, times := range checker.uidUsage { + if times > 1 { + log.Error("the same 'uid' is used more than once", "uid", uid, "provider", checker.provisioningProvider) + } + } + + for title, times := range checker.titleUsage { + if times > 1 { + log.Error("the same 'title' is used more than once", "title", title, "provider", checker.provisioningProvider) + } + } + +} diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index f2805196dde..cd5e3456734 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -19,16 +19,16 @@ var ( brokenDashboards string = "./test-dashboards/broken-dashboards" oneDashboard string = "./test-dashboards/one-dashboard" - fakeRepo *fakeDashboardRepo + fakeService *fakeDashboardProvisioningService ) func TestDashboardFileReader(t *testing.T) { Convey("Dashboard file reader", t, func() { bus.ClearBusHandlers() - fakeRepo = &fakeDashboardRepo{} + origNewDashboardProvisioningService := dashboards.NewProvisioningService + fakeService = mockDashboardProvisioningService() bus.AddHandler("test", mockGetDashboardQuery) - dashboards.SetRepository(fakeRepo) logger := log.New("test.logger") Convey("Reading dashboards from disk", func() { @@ -54,7 +54,7 @@ func TestDashboardFileReader(t *testing.T) { folders := 0 dashboards := 0 - for _, i := range fakeRepo.inserted { + for _, i := range fakeService.inserted { if i.Dashboard.IsFolder { folders++ } else { @@ -62,25 +62,8 @@ func TestDashboardFileReader(t *testing.T) { } } - So(dashboards, ShouldEqual, 2) So(folders, ShouldEqual, 1) - }) - - Convey("Should not update dashboards when db is newer", func() { - cfg.Options["path"] = oneDashboard - - fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{ - Updated: time.Now().Add(time.Hour), - Slug: "grafana", - }) - - reader, err := NewDashboardFileReader(cfg, logger) - So(err, ShouldBeNil) - - err = reader.startWalkingDisk() - So(err, ShouldBeNil) - - So(len(fakeRepo.inserted), ShouldEqual, 0) + So(dashboards, ShouldEqual, 2) }) Convey("Can read default dashboard and replace old version in database", func() { @@ -88,7 +71,7 @@ func TestDashboardFileReader(t *testing.T) { stat, _ := os.Stat(oneDashboard + "/dashboard1.json") - fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{ + fakeService.getDashboard = append(fakeService.getDashboard, &models.Dashboard{ Updated: stat.ModTime().AddDate(0, 0, -1), Slug: "grafana", }) @@ -99,7 +82,7 @@ func TestDashboardFileReader(t *testing.T) { err = reader.startWalkingDisk() So(err, ShouldBeNil) - So(len(fakeRepo.inserted), ShouldEqual, 1) + So(len(fakeService.inserted), ShouldEqual, 1) }) Convey("Invalid configuration should return error", func() { @@ -133,7 +116,7 @@ func TestDashboardFileReader(t *testing.T) { }, } - _, err := getOrCreateFolderId(cfg, fakeRepo) + _, err := getOrCreateFolderId(cfg, fakeService) So(err, ShouldEqual, ErrFolderNameMissing) }) @@ -148,39 +131,28 @@ func TestDashboardFileReader(t *testing.T) { }, } - folderId, err := getOrCreateFolderId(cfg, fakeRepo) + folderId, err := getOrCreateFolderId(cfg, fakeService) So(err, ShouldBeNil) inserted := false - for _, d := range fakeRepo.inserted { + for _, d := range fakeService.inserted { if d.Dashboard.IsFolder && d.Dashboard.Id == folderId { inserted = true } } - So(len(fakeRepo.inserted), ShouldEqual, 1) + So(len(fakeService.inserted), ShouldEqual, 1) So(inserted, ShouldBeTrue) }) Convey("Walking the folder with dashboards", func() { - cfg := &DashboardsAsConfig{ - Name: "Default", - Type: "file", - OrgId: 1, - Folder: "", - Options: map[string]interface{}{ - "path": defaultDashboards, - }, - } - - reader, err := NewDashboardFileReader(cfg, log.New("test-logger")) - So(err, ShouldBeNil) + noFiles := map[string]os.FileInfo{} Convey("should skip dirs that starts with .", func() { - shouldSkip := reader.createWalk(reader, 0)("path", &FakeFileInfo{isDirectory: true, name: ".folder"}, nil) + shouldSkip := createWalkFn(noFiles)("path", &FakeFileInfo{isDirectory: true, name: ".folder"}, nil) So(shouldSkip, ShouldEqual, filepath.SkipDir) }) Convey("should keep walking if file is not .json", func() { - shouldSkip := reader.createWalk(reader, 0)("path", &FakeFileInfo{isDirectory: true, name: "folder"}, nil) + shouldSkip := createWalkFn(noFiles)("path", &FakeFileInfo{isDirectory: true, name: "folder"}, nil) So(shouldSkip, ShouldBeNil) }) }) @@ -208,6 +180,10 @@ func TestDashboardFileReader(t *testing.T) { So(reader.Path, ShouldEqual, defaultDashboards) }) }) + + Reset(func() { + dashboards.NewProvisioningService = origNewDashboardProvisioningService + }) }) } @@ -240,18 +216,37 @@ func (ffi FakeFileInfo) Sys() interface{} { return nil } -type fakeDashboardRepo struct { - inserted []*dashboards.SaveDashboardItem +func mockDashboardProvisioningService() *fakeDashboardProvisioningService { + mock := fakeDashboardProvisioningService{} + dashboards.NewProvisioningService = func() dashboards.DashboardProvisioningService { + return &mock + } + return &mock +} + +type fakeDashboardProvisioningService struct { + inserted []*dashboards.SaveDashboardDTO + provisioned []*models.DashboardProvisioning getDashboard []*models.Dashboard } -func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*models.Dashboard, error) { - repo.inserted = append(repo.inserted, json) - return json.Dashboard, nil +func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + return s.provisioned, nil +} + +func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + s.inserted = append(s.inserted, dto) + s.provisioned = append(s.provisioned, provisioning) + return dto.Dashboard, nil +} + +func (s *fakeDashboardProvisioningService) SaveFolderForProvisionedDashboards(dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { + s.inserted = append(s.inserted, dto) + return dto.Dashboard, nil } func mockGetDashboardQuery(cmd *models.GetDashboardQuery) error { - for _, d := range fakeRepo.getDashboard { + for _, d := range fakeService.getDashboard { if d.Slug == cmd.Slug { cmd.Result = d return nil diff --git a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml index df0e6ff3044..e9776d69010 100644 --- a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml +++ b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml @@ -1,7 +1,11 @@ +apiVersion: 1 + +providers: - name: 'general dashboards' - org_id: 2 + orgId: 2 folder: 'developers' editable: true + disableDeletion: true type: file options: path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml new file mode 100644 index 00000000000..5b73632b1ff --- /dev/null +++ b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +#providers: +#- name: 'gasdf' +# orgId: 2 +# folder: 'developers' +# editable: true +# type: file +# options: +# path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml b/pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml new file mode 100644 index 00000000000..979e762d4d4 --- /dev/null +++ b/pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml @@ -0,0 +1,13 @@ +- name: 'general dashboards' + org_id: 2 + folder: 'developers' + editable: true + disableDeletion: true + type: file + options: + path: /var/lib/grafana/dashboards + +- name: 'default' + type: file + options: + path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json index 5b6765a4ed6..febb98be0e8 100644 --- a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json +++ b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json @@ -1,5 +1,5 @@ { - "title": "Grafana", + "title": "Grafana1", "tags": [], "style": "dark", "timezone": "browser", @@ -170,4 +170,3 @@ }, "version": 5 } - \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json index 5b6765a4ed6..9291f16d9e7 100644 --- a/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json +++ b/pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json @@ -1,5 +1,5 @@ { - "title": "Grafana", + "title": "Grafana2", "tags": [], "style": "dark", "timezone": "browser", @@ -170,4 +170,3 @@ }, "version": 5 } - \ No newline at end of file diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index cf65c65348c..f742b321552 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -10,16 +10,45 @@ import ( ) 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"` + Name string + Type string + OrgId int64 + Folder string + Editable bool + Options map[string]interface{} + DisableDeletion bool } -func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardItem, error) { - dash := &dashboards.SaveDashboardItem{} +type DashboardsAsConfigV0 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"` + DisableDeletion bool `json:"disableDeletion" yaml:"disableDeletion"` +} + +type ConfigVersion struct { + ApiVersion int64 `json:"apiVersion" yaml:"apiVersion"` +} + +type DashboardAsConfigV1 struct { + Providers []*DashboardProviderConfigs `json:"providers" yaml:"providers"` +} + +type DashboardProviderConfigs struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + OrgId int64 `json:"orgId" yaml:"orgId"` + Folder string `json:"folder" yaml:"folder"` + Editable bool `json:"editable" yaml:"editable"` + Options map[string]interface{} `json:"options" yaml:"options"` + DisableDeletion bool `json:"disableDeletion" yaml:"disableDeletion"` +} + +func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardDTO, error) { + dash := &dashboards.SaveDashboardDTO{} dash.Dashboard = models.NewDashboardFromJson(data) dash.UpdatedAt = lastModified dash.Overwrite = true @@ -36,3 +65,39 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das return dash, nil } + +func mapV0ToDashboardAsConfig(v0 []*DashboardsAsConfigV0) []*DashboardsAsConfig { + var r []*DashboardsAsConfig + + for _, v := range v0 { + r = append(r, &DashboardsAsConfig{ + Name: v.Name, + Type: v.Type, + OrgId: v.OrgId, + Folder: v.Folder, + Editable: v.Editable, + Options: v.Options, + DisableDeletion: v.DisableDeletion, + }) + } + + return r +} + +func (dc *DashboardAsConfigV1) mapToDashboardAsConfig() []*DashboardsAsConfig { + var r []*DashboardsAsConfig + + for _, v := range dc.Providers { + r = append(r, &DashboardsAsConfig{ + Name: v.Name, + Type: v.Type, + OrgId: v.OrgId, + Folder: v.Folder, + Editable: v.Editable, + Options: v.Options, + DisableDeletion: v.DisableDeletion, + }) + } + + return r +} diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go new file mode 100644 index 00000000000..58ed5472a6b --- /dev/null +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -0,0 +1,113 @@ +package datasources + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/log" + "gopkg.in/yaml.v2" +) + +type configReader struct { + log log.Logger +} + +func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { + var datasources []*DatasourcesAsConfig + + files, err := ioutil.ReadDir(path) + if err != nil { + cr.log.Error("cant read datasource provisioning files from directory", "path", path) + return datasources, nil + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { + datasource, err := cr.parseDatasourceConfig(path, file) + if err != nil { + return nil, err + } + + if datasource != nil { + datasources = append(datasources, datasource) + } + } + } + + err = validateDefaultUniqueness(datasources) + if err != nil { + return nil, err + } + + return datasources, nil +} + +func (cr *configReader) parseDatasourceConfig(path string, file os.FileInfo) (*DatasourcesAsConfig, error) { + filename, _ := filepath.Abs(filepath.Join(path, file.Name())) + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var apiVersion *ConfigVersion + err = yaml.Unmarshal(yamlFile, &apiVersion) + if err != nil { + return nil, err + } + + if apiVersion == nil { + apiVersion = &ConfigVersion{ApiVersion: 0} + } + + if apiVersion.ApiVersion > 0 { + var v1 *DatasourcesAsConfigV1 + err = yaml.Unmarshal(yamlFile, &v1) + if err != nil { + return nil, err + } + + return v1.mapToDatasourceFromConfig(apiVersion.ApiVersion), nil + } + + var v0 *DatasourcesAsConfigV0 + err = yaml.Unmarshal(yamlFile, &v0) + if err != nil { + return nil, err + } + + cr.log.Warn("[Deprecated] the datasource provisioning config is outdated. please upgrade", "filename", filename) + + return v0.mapToDatasourceFromConfig(apiVersion.ApiVersion), nil +} + +func validateDefaultUniqueness(datasources []*DatasourcesAsConfig) error { + defaultCount := 0 + for i := range datasources { + if datasources[i].Datasources == nil { + continue + } + + for _, ds := range datasources[i].Datasources { + if ds.OrgId == 0 { + ds.OrgId = 1 + } + + if ds.IsDefault { + defaultCount++ + if defaultCount > 1 { + return ErrInvalidConfigToManyDefault + } + } + } + + for _, ds := range datasources[i].DeleteDatasources { + if ds.OrgId == 0 { + ds.OrgId = 1 + } + } + } + + return nil +} diff --git a/pkg/services/provisioning/datasources/datasources_test.go b/pkg/services/provisioning/datasources/config_reader_test.go similarity index 69% rename from pkg/services/provisioning/datasources/datasources_test.go rename to pkg/services/provisioning/datasources/config_reader_test.go index 00dc59f6617..3198329e0ae 100644 --- a/pkg/services/provisioning/datasources/datasources_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -17,6 +17,7 @@ var ( twoDatasourcesConfigPurgeOthers string = "./test-configs/insert-two-delete-two" doubleDatasourcesConfig string = "./test-configs/double-default" allProperties string = "./test-configs/all-properties" + versionZero string = "./test-configs/version-0" brokenYaml string = "./test-configs/broken-yaml" fakeRepo *fakeRepository @@ -130,48 +131,86 @@ func TestDatasourceAsConfig(t *testing.T) { So(len(cfg), ShouldEqual, 0) }) - Convey("can read all properties", func() { + Convey("can read all properties from version 1", func() { cfgProvifer := &configReader{log: log.New("test logger")} cfg, err := cfgProvifer.readConfig(allProperties) if err != nil { t.Fatalf("readConfig return an error %v", err) } - So(len(cfg), ShouldEqual, 2) + So(len(cfg), ShouldEqual, 3) dsCfg := cfg[0] - ds := dsCfg.Datasources[0] - So(ds.Name, ShouldEqual, "name") - So(ds.Type, ShouldEqual, "type") - So(ds.Access, ShouldEqual, models.DS_ACCESS_PROXY) - So(ds.OrgId, ShouldEqual, 2) - So(ds.Url, ShouldEqual, "url") - So(ds.User, ShouldEqual, "user") - So(ds.Password, ShouldEqual, "password") - So(ds.Database, ShouldEqual, "database") - So(ds.BasicAuth, ShouldBeTrue) - So(ds.BasicAuthUser, ShouldEqual, "basic_auth_user") - So(ds.BasicAuthPassword, ShouldEqual, "basic_auth_password") - So(ds.WithCredentials, ShouldBeTrue) - So(ds.IsDefault, ShouldBeTrue) - So(ds.Editable, ShouldBeTrue) + So(dsCfg.ApiVersion, ShouldEqual, 1) - So(len(ds.JsonData), ShouldBeGreaterThan, 2) - So(ds.JsonData["graphiteVersion"], ShouldEqual, "1.1") - So(ds.JsonData["tlsAuth"], ShouldEqual, true) - So(ds.JsonData["tlsAuthWithCACert"], ShouldEqual, true) + validateDatasource(dsCfg) + validateDeleteDatasources(dsCfg) - So(len(ds.SecureJsonData), ShouldBeGreaterThan, 2) - So(ds.SecureJsonData["tlsCACert"], ShouldEqual, "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==") - So(ds.SecureJsonData["tlsClientCert"], ShouldEqual, "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==") - So(ds.SecureJsonData["tlsClientKey"], ShouldEqual, "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==") + dsCount := 0 + delDsCount := 0 - dstwo := cfg[1].Datasources[0] - So(dstwo.Name, ShouldEqual, "name2") + for _, c := range cfg { + dsCount += len(c.Datasources) + delDsCount += len(c.DeleteDatasources) + } + + So(dsCount, ShouldEqual, 2) + So(delDsCount, ShouldEqual, 1) + }) + + Convey("can read all properties from version 0", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + cfg, err := cfgProvifer.readConfig(versionZero) + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + + So(len(cfg), ShouldEqual, 1) + + dsCfg := cfg[0] + + So(dsCfg.ApiVersion, ShouldEqual, 0) + + validateDatasource(dsCfg) + validateDeleteDatasources(dsCfg) }) }) } +func validateDeleteDatasources(dsCfg *DatasourcesAsConfig) { + So(len(dsCfg.DeleteDatasources), ShouldEqual, 1) + deleteDs := dsCfg.DeleteDatasources[0] + So(deleteDs.Name, ShouldEqual, "old-graphite3") + So(deleteDs.OrgId, ShouldEqual, 2) +} +func validateDatasource(dsCfg *DatasourcesAsConfig) { + ds := dsCfg.Datasources[0] + So(ds.Name, ShouldEqual, "name") + So(ds.Type, ShouldEqual, "type") + So(ds.Access, ShouldEqual, models.DS_ACCESS_PROXY) + So(ds.OrgId, ShouldEqual, 2) + So(ds.Url, ShouldEqual, "url") + So(ds.User, ShouldEqual, "user") + So(ds.Password, ShouldEqual, "password") + So(ds.Database, ShouldEqual, "database") + So(ds.BasicAuth, ShouldBeTrue) + So(ds.BasicAuthUser, ShouldEqual, "basic_auth_user") + So(ds.BasicAuthPassword, ShouldEqual, "basic_auth_password") + So(ds.WithCredentials, ShouldBeTrue) + So(ds.IsDefault, ShouldBeTrue) + So(ds.Editable, ShouldBeTrue) + So(ds.Version, ShouldEqual, 10) + + So(len(ds.JsonData), ShouldBeGreaterThan, 2) + So(ds.JsonData["graphiteVersion"], ShouldEqual, "1.1") + So(ds.JsonData["tlsAuth"], ShouldEqual, true) + So(ds.JsonData["tlsAuthWithCACert"], ShouldEqual, true) + + So(len(ds.SecureJsonData), ShouldBeGreaterThan, 2) + So(ds.SecureJsonData["tlsCACert"], ShouldEqual, "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==") + So(ds.SecureJsonData["tlsClientCert"], ShouldEqual, "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==") + So(ds.SecureJsonData["tlsClientKey"], ShouldEqual, "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==") +} type fakeRepository struct { inserted []*models.AddDataSourceCommand diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index aa1308ffa29..1fa0a3b3173 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -2,16 +2,12 @@ package datasources import ( "errors" - "io/ioutil" - "path/filepath" - "strings" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" - yaml "gopkg.in/yaml.v2" ) var ( @@ -94,65 +90,3 @@ func (dc *DatasourceProvisioner) deleteDatasources(dsToDelete []*DeleteDatasourc return nil } - -type configReader struct { - log log.Logger -} - -func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { - var datasources []*DatasourcesAsConfig - - files, err := ioutil.ReadDir(path) - if err != nil { - cr.log.Error("cant read datasource provisioning files from directory", "path", path) - return datasources, nil - } - - for _, file := range files { - if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { - filename, _ := filepath.Abs(filepath.Join(path, file.Name())) - yamlFile, err := ioutil.ReadFile(filename) - - if err != nil { - return nil, err - } - var datasource *DatasourcesAsConfig - err = yaml.Unmarshal(yamlFile, &datasource) - if err != nil { - return nil, err - } - - if datasource != nil { - datasources = append(datasources, datasource) - } - } - } - - defaultCount := 0 - for i := range datasources { - if datasources[i].Datasources == nil { - continue - } - - for _, ds := range datasources[i].Datasources { - if ds.OrgId == 0 { - ds.OrgId = 1 - } - - if ds.IsDefault { - defaultCount++ - if defaultCount > 1 { - return nil, ErrInvalidConfigToManyDefault - } - } - } - - for _, ds := range datasources[i].DeleteDatasources { - if ds.OrgId == 0 { - ds.OrgId = 1 - } - } - } - - return datasources, nil -} diff --git a/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml b/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml index af0d3009a4c..b92b81f7079 100644 --- a/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml +++ b/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml @@ -1,23 +1,30 @@ +apiVersion: 1 + datasources: - name: name type: type access: proxy - org_id: 2 + orgId: 2 url: url password: password user: user database: database - basic_auth: true - basic_auth_user: basic_auth_user - basic_auth_password: basic_auth_password - with_credentials: true - is_default: true - json_data: + basicAuth: true + basicAuthUser: basic_auth_user + basicAuthPassword: basic_auth_password + withCredentials: true + isDefault: true + jsonData: graphiteVersion: "1.1" tlsAuth: true tlsAuthWithCACert: true - secure_json_data: + secureJsonData: tlsCACert: "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==" tlsClientCert: "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==" tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" editable: true + version: 10 + +deleteDatasources: + - name: old-graphite3 + orgId: 2 diff --git a/pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml b/pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml new file mode 100644 index 00000000000..2187eabdc46 --- /dev/null +++ b/pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml @@ -0,0 +1,32 @@ +# Should not be included + + +apiVersion: 1 + +#datasources: +# - name: name +# type: type +# access: proxy +# orgId: 2 +# url: url +# password: password +# user: user +# database: database +# basicAuth: true +# basicAuthUser: basic_auth_user +# basicAuthPassword: basic_auth_password +# withCredentials: true +# jsonData: +# graphiteVersion: "1.1" +# tlsAuth: true +# tlsAuthWithCACert: true +# secureJsonData: +# tlsCACert: "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==" +# tlsClientCert: "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==" +# tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" +# editable: true +# version: 10 +# +#deleteDatasources: +# - name: old-graphite3 +# orgId: 2 diff --git a/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml b/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml index 43c41ee9b3b..9f27a8d07ee 100644 --- a/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml +++ b/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml @@ -3,5 +3,5 @@ datasources: - name: name2 type: type2 access: proxy - org_id: 2 + orgId: 2 url: url2 diff --git a/pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml b/pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml new file mode 100644 index 00000000000..fcd4ddd6b01 --- /dev/null +++ b/pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml @@ -0,0 +1,28 @@ +datasources: + - name: name + type: type + access: proxy + org_id: 2 + url: url + password: password + user: user + database: database + basic_auth: true + basic_auth_user: basic_auth_user + basic_auth_password: basic_auth_password + with_credentials: true + is_default: true + json_data: + graphiteVersion: "1.1" + tlsAuth: true + tlsAuthWithCACert: true + secure_json_data: + tlsCACert: "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==" + tlsClientCert: "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==" + tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" + editable: true + version: 10 + +delete_datasources: + - name: old-graphite3 + org_id: 2 diff --git a/pkg/services/provisioning/datasources/types.go b/pkg/services/provisioning/datasources/types.go index ee2175d6a90..8e2443a0169 100644 --- a/pkg/services/provisioning/datasources/types.go +++ b/pkg/services/provisioning/datasources/types.go @@ -1,22 +1,74 @@ package datasources -import "github.com/grafana/grafana/pkg/models" +import ( + "github.com/grafana/grafana/pkg/models" +) import "github.com/grafana/grafana/pkg/components/simplejson" +type ConfigVersion struct { + ApiVersion int64 `json:"apiVersion" yaml:"apiVersion"` +} + type DatasourcesAsConfig struct { - Datasources []*DataSourceFromConfig `json:"datasources" yaml:"datasources"` - DeleteDatasources []*DeleteDatasourceConfig `json:"delete_datasources" yaml:"delete_datasources"` + ApiVersion int64 + + Datasources []*DataSourceFromConfig + DeleteDatasources []*DeleteDatasourceConfig } type DeleteDatasourceConfig struct { + OrgId int64 + Name string +} + +type DataSourceFromConfig struct { + OrgId int64 + Version int + + Name string + Type string + Access string + Url string + Password string + User string + Database string + BasicAuth bool + BasicAuthUser string + BasicAuthPassword string + WithCredentials bool + IsDefault bool + JsonData map[string]interface{} + SecureJsonData map[string]string + Editable bool +} + +type DatasourcesAsConfigV0 struct { + ConfigVersion + + Datasources []*DataSourceFromConfigV0 `json:"datasources" yaml:"datasources"` + DeleteDatasources []*DeleteDatasourceConfigV0 `json:"delete_datasources" yaml:"delete_datasources"` +} + +type DatasourcesAsConfigV1 struct { + ConfigVersion + + Datasources []*DataSourceFromConfigV1 `json:"datasources" yaml:"datasources"` + DeleteDatasources []*DeleteDatasourceConfigV1 `json:"deleteDatasources" yaml:"deleteDatasources"` +} + +type DeleteDatasourceConfigV0 struct { OrgId int64 `json:"org_id" yaml:"org_id"` Name string `json:"name" yaml:"name"` } -type DataSourceFromConfig struct { - OrgId int64 `json:"org_id" yaml:"org_id"` - Version int `json:"version" yaml:"version"` +type DeleteDatasourceConfigV1 struct { + OrgId int64 `json:"orgId" yaml:"orgId"` + Name string `json:"name" yaml:"name"` +} +type DataSourceFromConfigV0 struct { + OrgId int64 `json:"org_id" yaml:"org_id"` + Version int `json:"version" yaml:"version"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` Access string `json:"access" yaml:"access"` @@ -34,6 +86,108 @@ type DataSourceFromConfig struct { Editable bool `json:"editable" yaml:"editable"` } +type DataSourceFromConfigV1 struct { + OrgId int64 `json:"orgId" yaml:"orgId"` + Version int `json:"version" yaml:"version"` + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + Access string `json:"access" yaml:"access"` + Url string `json:"url" yaml:"url"` + Password string `json:"password" yaml:"password"` + User string `json:"user" yaml:"user"` + Database string `json:"database" yaml:"database"` + BasicAuth bool `json:"basicAuth" yaml:"basicAuth"` + BasicAuthUser string `json:"basicAuthUser" yaml:"basicAuthUser"` + BasicAuthPassword string `json:"basicAuthPassword" yaml:"basicAuthPassword"` + WithCredentials bool `json:"withCredentials" yaml:"withCredentials"` + IsDefault bool `json:"isDefault" yaml:"isDefault"` + JsonData map[string]interface{} `json:"jsonData" yaml:"jsonData"` + SecureJsonData map[string]string `json:"secureJsonData" yaml:"secureJsonData"` + Editable bool `json:"editable" yaml:"editable"` +} + +func (cfg *DatasourcesAsConfigV1) mapToDatasourceFromConfig(apiVersion int64) *DatasourcesAsConfig { + r := &DatasourcesAsConfig{} + + r.ApiVersion = apiVersion + + if cfg == nil { + return r + } + + for _, ds := range cfg.Datasources { + r.Datasources = append(r.Datasources, &DataSourceFromConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + Type: ds.Type, + Access: ds.Access, + Url: ds.Url, + Password: ds.Password, + User: ds.User, + Database: ds.Database, + BasicAuth: ds.BasicAuth, + BasicAuthUser: ds.BasicAuthUser, + BasicAuthPassword: ds.BasicAuthPassword, + WithCredentials: ds.WithCredentials, + IsDefault: ds.IsDefault, + JsonData: ds.JsonData, + SecureJsonData: ds.SecureJsonData, + Editable: ds.Editable, + Version: ds.Version, + }) + } + + for _, ds := range cfg.DeleteDatasources { + r.DeleteDatasources = append(r.DeleteDatasources, &DeleteDatasourceConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + }) + } + + return r +} + +func (cfg *DatasourcesAsConfigV0) mapToDatasourceFromConfig(apiVersion int64) *DatasourcesAsConfig { + r := &DatasourcesAsConfig{} + + r.ApiVersion = apiVersion + + if cfg == nil { + return r + } + + for _, ds := range cfg.Datasources { + r.Datasources = append(r.Datasources, &DataSourceFromConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + Type: ds.Type, + Access: ds.Access, + Url: ds.Url, + Password: ds.Password, + User: ds.User, + Database: ds.Database, + BasicAuth: ds.BasicAuth, + BasicAuthUser: ds.BasicAuthUser, + BasicAuthPassword: ds.BasicAuthPassword, + WithCredentials: ds.WithCredentials, + IsDefault: ds.IsDefault, + JsonData: ds.JsonData, + SecureJsonData: ds.SecureJsonData, + Editable: ds.Editable, + Version: ds.Version, + }) + } + + for _, ds := range cfg.DeleteDatasources { + r.DeleteDatasources = append(r.DeleteDatasources, &DeleteDatasourceConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + }) + } + + return r +} + func createInsertCommand(ds *DataSourceFromConfig) *models.AddDataSourceCommand { jsonData := simplejson.New() if len(ds.JsonData) > 0 { diff --git a/pkg/services/search/handlers.go b/pkg/services/search/handlers.go index 247585402ef..cf194c320bb 100644 --- a/pkg/services/search/handlers.go +++ b/pkg/services/search/handlers.go @@ -21,6 +21,7 @@ func searchHandler(query *Query) error { FolderIds: query.FolderIds, Tags: query.Tags, Limit: query.Limit, + Permission: query.Permission, } if err := bus.Dispatch(&dashQuery); err != nil { diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go index 6dea975d9fe..2da09672f13 100644 --- a/pkg/services/search/models.go +++ b/pkg/services/search/models.go @@ -52,6 +52,7 @@ type Query struct { Type string DashboardIds []int64 FolderIds []int64 + Permission models.PermissionType Result HitList } @@ -66,7 +67,7 @@ type FindPersistedDashboardsQuery struct { FolderIds []int64 Tags []string Limit int - IsBrowse bool + Permission models.PermissionType Result HitList } diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 96af8bc49ee..8c751f0cada 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -61,52 +61,61 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro } func HandleAlertsQuery(query *m.GetAlertsQuery) error { - var sql bytes.Buffer - params := make([]interface{}, 0) + builder := SqlBuilder{} - sql.WriteString(`SELECT * - from alert - `) + builder.Write(`SELECT + alert.id, + alert.dashboard_id, + alert.panel_id, + alert.name, + alert.state, + alert.new_state_date, + alert.eval_date, + alert.execution_error, + dashboard.uid as dashboard_uid, + dashboard.slug as dashboard_slug + FROM alert + INNER JOIN dashboard on dashboard.id = alert.dashboard_id `) - sql.WriteString(`WHERE org_id = ?`) - params = append(params, query.OrgId) + builder.Write(`WHERE alert.org_id = ?`, query.OrgId) if query.DashboardId != 0 { - sql.WriteString(` AND dashboard_id = ?`) - params = append(params, query.DashboardId) + builder.Write(` AND alert.dashboard_id = ?`, query.DashboardId) } if query.PanelId != 0 { - sql.WriteString(` AND panel_id = ?`) - params = append(params, query.PanelId) + builder.Write(` AND alert.panel_id = ?`, query.PanelId) } if len(query.State) > 0 && query.State[0] != "all" { - sql.WriteString(` AND (`) + builder.Write(` AND (`) for i, v := range query.State { if i > 0 { - sql.WriteString(" OR ") + builder.Write(" OR ") } if strings.HasPrefix(v, "not_") { - sql.WriteString("state <> ? ") + builder.Write("state <> ? ") v = strings.TrimPrefix(v, "not_") } else { - sql.WriteString("state = ? ") + builder.Write("state = ? ") } - params = append(params, v) + builder.AddParams(v) } - sql.WriteString(")") + builder.Write(")") } - sql.WriteString(" ORDER BY name ASC") + if query.User.OrgRole != m.ROLE_ADMIN { + builder.writeDashboardPermissionFilter(query.User, m.PERMISSION_EDIT) + } + + builder.Write(" ORDER BY name ASC") if query.Limit != 0 { - sql.WriteString(" LIMIT ?") - params = append(params, query.Limit) + builder.Write(" LIMIT ?", query.Limit) } - alerts := make([]*m.Alert, 0) - if err := x.SQL(sql.String(), params...).Find(&alerts); err != nil { + alerts := make([]*m.AlertListItemDTO, 0) + if err := x.SQL(builder.GetSqlString(), builder.params...).Find(&alerts); err != nil { return err } diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index 7b27f5b9ca4..de86ae87a4f 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -71,15 +71,21 @@ func TestAlertingDataAccess(t *testing.T) { }) Convey("Can read properties", func() { - alertQuery := m.GetAlertsQuery{DashboardId: testDash.Id, PanelId: 1, OrgId: 1} + alertQuery := m.GetAlertsQuery{DashboardId: testDash.Id, PanelId: 1, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&alertQuery) alert := alertQuery.Result[0] So(err2, ShouldBeNil) So(alert.Name, ShouldEqual, "Alerting title") - So(alert.Message, ShouldEqual, "Alerting message") So(alert.State, ShouldEqual, "pending") - So(alert.Frequency, ShouldEqual, 1) + }) + + Convey("Viewer cannot read alerts", func() { + alertQuery := m.GetAlertsQuery{DashboardId: testDash.Id, PanelId: 1, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_VIEWER}} + err2 := HandleAlertsQuery(&alertQuery) + + So(err2, ShouldBeNil) + So(alertQuery.Result, ShouldHaveLength, 0) }) Convey("Alerts with same dashboard id and panel id should update", func() { @@ -100,7 +106,7 @@ func TestAlertingDataAccess(t *testing.T) { }) Convey("Alerts should be updated", func() { - query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&query) So(err2, ShouldBeNil) @@ -149,7 +155,7 @@ func TestAlertingDataAccess(t *testing.T) { Convey("Should save 3 dashboards", func() { So(err, ShouldBeNil) - queryForDashboard := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + queryForDashboard := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&queryForDashboard) So(err2, ShouldBeNil) @@ -163,7 +169,7 @@ func TestAlertingDataAccess(t *testing.T) { err = SaveAlerts(&cmd) Convey("should delete the missing alert", func() { - query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&query) So(err2, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -198,7 +204,7 @@ func TestAlertingDataAccess(t *testing.T) { So(err, ShouldBeNil) Convey("Alerts should be removed", func() { - query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&query) So(testDash.Id, ShouldEqual, 1) diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 1445d25432a..8516e7c46e7 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -21,195 +21,120 @@ func init() { bus.AddHandler("sql", GetDashboardSlugById) bus.AddHandler("sql", GetDashboardUIDById) bus.AddHandler("sql", GetDashboardsByPluginId) - bus.AddHandler("sql", GetFoldersForSignedInUser) bus.AddHandler("sql", GetDashboardPermissionsForUser) bus.AddHandler("sql", GetDashboardsBySlug) + bus.AddHandler("sql", ValidateDashboardBeforeSave) } var generateNewUid func() string = util.GenerateShortUid func SaveDashboard(cmd *m.SaveDashboardCommand) error { return inTransaction(func(sess *DBSession) error { - dash := cmd.GetDashboardModel() - - if err := getExistingDashboardForUpdate(sess, dash, cmd); err != nil { - return err - } - - var existingByTitleAndFolder m.Dashboard - - dashWithTitleAndFolderExists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, dialect.BooleanStr(true), dash.FolderId).Get(&existingByTitleAndFolder) - if err != nil { - return err - } - - if dashWithTitleAndFolderExists { - if dash.Id != existingByTitleAndFolder.Id { - if existingByTitleAndFolder.IsFolder && !cmd.IsFolder { - return m.ErrDashboardWithSameNameAsFolder - } - - if !existingByTitleAndFolder.IsFolder && cmd.IsFolder { - return m.ErrDashboardFolderWithSameNameAsDashboard - } - - if cmd.Overwrite { - dash.Id = existingByTitleAndFolder.Id - dash.Version = existingByTitleAndFolder.Version - - if dash.Uid == "" { - dash.Uid = existingByTitleAndFolder.Uid - } - } else { - return m.ErrDashboardWithSameNameInFolderExists - } - } - } - - if dash.Uid == "" { - uid, err := generateNewDashboardUid(sess, dash.OrgId) - if err != nil { - return err - } - dash.Uid = uid - dash.Data.Set("uid", uid) - } - - err = setHasAcl(sess, dash) - if err != nil { - return err - } - - parentVersion := dash.Version - affectedRows := int64(0) - - if dash.Id == 0 { - dash.Version = 1 - metrics.M_Api_Dashboard_Insert.Inc() - dash.Data.Set("version", dash.Version) - affectedRows, err = sess.Insert(dash) - } else { - dash.Version++ - dash.Data.Set("version", dash.Version) - - if !cmd.UpdatedAt.IsZero() { - dash.Updated = cmd.UpdatedAt - } - - affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash) - } - - if err != nil { - return err - } - - if affectedRows == 0 { - return m.ErrDashboardNotFound - } - - dashVersion := &m.DashboardVersion{ - DashboardId: dash.Id, - ParentVersion: parentVersion, - RestoredFrom: cmd.RestoredFrom, - Version: dash.Version, - Created: time.Now(), - CreatedBy: dash.UpdatedBy, - Message: cmd.Message, - Data: dash.Data, - } - - // insert version entry - if affectedRows, err = sess.Insert(dashVersion); err != nil { - return err - } else if affectedRows == 0 { - return m.ErrDashboardNotFound - } - - // delete existing tags - _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) - if err != nil { - return err - } - - // insert new tags - tags := dash.GetTags() - if len(tags) > 0 { - for _, tag := range tags { - if _, err := sess.Insert(&DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { - return err - } - } - } - cmd.Result = dash - - return err + return saveDashboard(sess, cmd) }) } -func getExistingDashboardForUpdate(sess *DBSession, dash *m.Dashboard, cmd *m.SaveDashboardCommand) (err error) { - dashWithIdExists := false - var existingById m.Dashboard +func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { + dash := cmd.GetDashboardModel() if dash.Id > 0 { - dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) + var existing m.Dashboard + dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) if err != nil { return err } - if !dashWithIdExists { return m.ErrDashboardNotFound } - if dash.Uid == "" { - dash.Uid = existingById.Uid + // check for is someone else has written in between + if dash.Version != existing.Version { + if cmd.Overwrite { + dash.SetVersion(existing.Version) + } else { + return m.ErrDashboardVersionMismatch + } + } + + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && cmd.Overwrite == false { + return m.UpdatePluginDashboardError{PluginId: existing.PluginId} } } - dashWithUidExists := false - var existingByUid m.Dashboard - - if dash.Uid != "" { - dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) + if dash.Uid == "" { + uid, err := generateNewDashboardUid(sess, dash.OrgId) if err != nil { return err } + dash.SetUid(uid) } - if !dashWithIdExists && !dashWithUidExists { - return nil + parentVersion := dash.Version + affectedRows := int64(0) + var err error + + if dash.Id == 0 { + dash.SetVersion(1) + metrics.M_Api_Dashboard_Insert.Inc() + affectedRows, err = sess.Insert(dash) + } else { + v := dash.Version + v++ + dash.SetVersion(v) + + if !cmd.UpdatedAt.IsZero() { + dash.Updated = cmd.UpdatedAt + } + + affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) } - if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { - return m.ErrDashboardWithSameUIDExists + if err != nil { + return err } - existing := existingById - - if !dashWithIdExists && dashWithUidExists { - dash.Id = existingByUid.Id - existing = existingByUid + if affectedRows == 0 { + return m.ErrDashboardNotFound } - if (existing.IsFolder && !cmd.IsFolder) || - (!existing.IsFolder && cmd.IsFolder) { - return m.ErrDashboardTypeMismatch + dashVersion := &m.DashboardVersion{ + DashboardId: dash.Id, + ParentVersion: parentVersion, + RestoredFrom: cmd.RestoredFrom, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, } - // check for is someone else has written in between - if dash.Version != existing.Version { - if cmd.Overwrite { - dash.Version = existing.Version - } else { - return m.ErrDashboardVersionMismatch + // insert version entry + if affectedRows, err = sess.Insert(dashVersion); err != nil { + return err + } else if affectedRows == 0 { + return m.ErrDashboardNotFound + } + + // delete existing tags + _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) + if err != nil { + return err + } + + // insert new tags + tags := dash.GetTags() + if len(tags) > 0 { + for _, tag := range tags { + if _, err := sess.Insert(&DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { + return err + } } } - // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && cmd.Overwrite == false { - return m.UpdatePluginDashboardError{PluginId: existing.PluginId} - } + cmd.Result = dash - return nil + return err } func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { @@ -229,31 +154,6 @@ func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { return "", m.ErrDashboardFailedGenerateUniqueUid } -func setHasAcl(sess *DBSession, dash *m.Dashboard) error { - // check if parent has acl - if dash.FolderId > 0 { - var parent m.Dashboard - if hasParent, err := sess.Where("folder_id=?", dash.FolderId).Get(&parent); err != nil { - return err - } else if hasParent && parent.HasAcl { - dash.HasAcl = true - } - } - - // check if dash has its own acl - if dash.Id > 0 { - if res, err := sess.Query("SELECT 1 from dashboard_acl WHERE dashboard_id =?", dash.Id); err != nil { - return err - } else { - if len(res) > 0 { - dash.HasAcl = true - } - } - } - - return nil -} - func GetDashboard(query *m.GetDashboardQuery) error { dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid} has, err := x.Get(&dashboard) @@ -264,8 +164,8 @@ func GetDashboard(query *m.GetDashboardQuery) error { return m.ErrDashboardNotFound } - dashboard.Data.Set("id", dashboard.Id) - dashboard.Data.Set("uid", dashboard.Uid) + dashboard.SetId(dashboard.Id) + dashboard.SetUid(dashboard.Uid) query.Result = &dashboard return nil } @@ -289,7 +189,7 @@ func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSear limit = 1000 } - sb := NewSearchBuilder(query.SignedInUser, limit). + sb := NewSearchBuilder(query.SignedInUser, limit, query.Permission). WithTags(query.Tags). WithDashboardIdsIn(query.DashboardIds) @@ -390,54 +290,6 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { return err } -func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error { - query.Result = make([]*m.DashboardFolder, 0) - var err error - - if query.SignedInUser.OrgRole == m.ROLE_ADMIN { - sql := `SELECT distinct d.id, d.title - FROM dashboard AS d WHERE d.is_folder = ? AND d.org_id = ? - ORDER BY d.title ASC` - - err = x.Sql(sql, dialect.BooleanStr(true), query.OrgId).Find(&query.Result) - } else { - params := make([]interface{}, 0) - sql := `SELECT distinct d.id, d.title - FROM dashboard AS d - LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id - LEFT JOIN team_member AS ugm ON ugm.team_id = da.team_id - LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? - LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ? AND ouRole.org_id = ?` - params = append(params, query.SignedInUser.UserId) - params = append(params, query.SignedInUser.UserId) - params = append(params, query.OrgId) - - sql += ` WHERE - d.org_id = ? AND - d.is_folder = ? AND - ( - (d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) - OR (d.has_acl = ? AND ouRole.id IS NOT NULL) - )` - params = append(params, query.OrgId) - params = append(params, dialect.BooleanStr(true)) - params = append(params, dialect.BooleanStr(true)) - params = append(params, query.SignedInUser.UserId) - params = append(params, query.SignedInUser.UserId) - params = append(params, dialect.BooleanStr(false)) - - if len(query.Title) > 0 { - sql += " AND d.title " + dialect.LikeStr() + " ?" - params = append(params, "%"+query.Title+"%") - } - - sql += ` ORDER BY d.title ASC` - err = x.Sql(sql, params...).Find(&query.Result) - } - - return err -} - func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { return inTransaction(func(sess *DBSession) error { dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId} @@ -456,6 +308,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM dashboard_version WHERE dashboard_id = ?", "DELETE FROM dashboard WHERE folder_id = ?", "DELETE FROM annotation WHERE dashboard_id = ?", + "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", } for _, sql := range deletes { @@ -621,3 +474,128 @@ func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error { query.Result = us return nil } + +func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDashboardBeforeSaveCommand) (err error) { + dash := cmd.Dashboard + + dashWithIdExists := false + var existingById m.Dashboard + + if dash.Id > 0 { + dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) + if err != nil { + return err + } + + if !dashWithIdExists { + return m.ErrDashboardNotFound + } + + if dash.Uid == "" { + dash.SetUid(existingById.Uid) + } + } + + dashWithUidExists := false + var existingByUid m.Dashboard + + if dash.Uid != "" { + dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) + if err != nil { + return err + } + } + + if dash.FolderId > 0 { + var existingFolder m.Dashboard + folderExists, folderErr := sess.Where("org_id=? AND id=? AND is_folder=?", dash.OrgId, dash.FolderId, dialect.BooleanStr(true)).Get(&existingFolder) + if folderErr != nil { + return folderErr + } + + if !folderExists { + return m.ErrFolderNotFound + } + } + + if !dashWithIdExists && !dashWithUidExists { + return nil + } + + if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { + return m.ErrDashboardWithSameUIDExists + } + + existing := existingById + + if !dashWithIdExists && dashWithUidExists { + dash.SetId(existingByUid.Id) + dash.SetUid(existingByUid.Uid) + existing = existingByUid + } + + if (existing.IsFolder && !dash.IsFolder) || + (!existing.IsFolder && dash.IsFolder) { + return m.ErrDashboardTypeMismatch + } + + // check for is someone else has written in between + if dash.Version != existing.Version { + if cmd.Overwrite { + dash.SetVersion(existing.Version) + } else { + return m.ErrDashboardVersionMismatch + } + } + + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && cmd.Overwrite == false { + return m.UpdatePluginDashboardError{PluginId: existing.PluginId} + } + + return nil +} + +func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *m.ValidateDashboardBeforeSaveCommand) error { + dash := cmd.Dashboard + var existing m.Dashboard + + exists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, dialect.BooleanStr(true), dash.FolderId).Get(&existing) + if err != nil { + return err + } + + if exists && dash.Id != existing.Id { + if existing.IsFolder && !dash.IsFolder { + return m.ErrDashboardWithSameNameAsFolder + } + + if !existing.IsFolder && dash.IsFolder { + return m.ErrDashboardFolderWithSameNameAsDashboard + } + + if cmd.Overwrite { + dash.SetId(existing.Id) + dash.SetUid(existing.Uid) + dash.SetVersion(existing.Version) + } else { + return m.ErrDashboardWithSameNameInFolderExists + } + } + + return nil +} + +func ValidateDashboardBeforeSave(cmd *m.ValidateDashboardBeforeSaveCommand) (err error) { + return inTransaction(func(sess *DBSession) error { + if err = getExistingDashboardByIdOrUidForUpdate(sess, cmd); err != nil { + return err + } + + if err = getExistingDashboardByTitleAndFolder(sess, cmd); err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 829182a8195..ae91d1d41f3 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -1,17 +1,12 @@ package sqlstore import ( - "fmt" - "time" - "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" ) func init() { - bus.AddHandler("sql", SetDashboardAcl) bus.AddHandler("sql", UpdateDashboardAcl) - bus.AddHandler("sql", RemoveDashboardAcl) bus.AddHandler("sql", GetDashboardAclInfoList) } @@ -24,7 +19,7 @@ func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error { } for _, item := range cmd.Items { - if item.UserId == 0 && item.TeamId == 0 && !item.Role.IsValid() { + if item.UserId == 0 && item.TeamId == 0 && (item.Role == nil || !item.Role.IsValid()) { return m.ErrDashboardAclInfoMissing } @@ -40,92 +35,13 @@ func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error { // Update dashboard HasAcl flag dashboard := m.Dashboard{HasAcl: true} - if _, err := sess.Cols("has_acl").Where("id=? OR folder_id=?", cmd.DashboardId, cmd.DashboardId).Update(&dashboard); err != nil { + if _, err := sess.Cols("has_acl").Where("id=?", cmd.DashboardId).Update(&dashboard); err != nil { return err } return nil }) } -func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error { - return inTransaction(func(sess *DBSession) error { - if cmd.UserId == 0 && cmd.TeamId == 0 { - return m.ErrDashboardAclInfoMissing - } - - if cmd.DashboardId == 0 { - return m.ErrDashboardPermissionDashboardEmpty - } - - 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 { - - entity := m.DashboardAcl{ - Permission: cmd.Permission, - Updated: time.Now(), - } - - 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 - } - - return nil - } - - entity := m.DashboardAcl{ - OrgId: cmd.OrgId, - TeamId: cmd.TeamId, - UserId: cmd.UserId, - Created: time.Now(), - Updated: time.Now(), - DashboardId: cmd.DashboardId, - Permission: cmd.Permission, - } - - cols := []string{"org_id", "created", "updated", "dashboard_id", "permission"} - - if cmd.UserId != 0 { - cols = append(cols, "user_id") - } - - if cmd.TeamId != 0 { - cols = append(cols, "team_id") - } - - _, err := sess.Cols(cols...).Insert(&entity) - if err != nil { - return err - } - - cmd.Result = entity - - // Update dashboard HasAcl flag - dashboard := m.Dashboard{ - HasAcl: true, - } - - if _, err := sess.Cols("has_acl").Where("id=? OR folder_id=?", cmd.DashboardId, cmd.DashboardId).Update(&dashboard); err != nil { - return err - } - - return nil - }) -} - -// RemoveDashboardAcl removes a specified permission from the dashboard acl -func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { - return inTransaction(func(sess *DBSession) error { - var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?" - _, err := sess.Exec(rawSQL, cmd.OrgId, cmd.AclId) - if err != nil { - return err - } - - return err - }) -} - // GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three // different places. // 1) Permissions for the dashboard @@ -134,6 +50,8 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { var err error + falseStr := dialect.BooleanStr(false) + if query.DashboardId == 0 { sql := `SELECT da.id, @@ -151,18 +69,13 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { '' as title, '' as slug, '' as uid,` + - dialect.BooleanStr(false) + ` AS is_folder + falseStr + ` AS is_folder FROM dashboard_acl as da WHERE da.dashboard_id = -1` query.Result = make([]*m.DashboardAclInfoDTO, 0) err = x.SQL(sql).Find(&query.Result) } else { - dashboardFilter := fmt.Sprintf(`IN ( - SELECT %d - UNION - SELECT folder_id from dashboard where id = %d - )`, query.DashboardId, query.DashboardId) rawSQL := ` -- get permissions for the dashboard and its parent folder @@ -183,41 +96,21 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { d.slug, d.uid, d.is_folder - FROM` + dialect.Quote("dashboard_acl") + ` as da - LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id - LEFT OUTER JOIN team ug on ug.id = da.team_id - LEFT OUTER JOIN dashboard d on da.dashboard_id = d.id - WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? - - -- Also include default permissions if folder or dashboard field "has_acl" is false - - UNION - SELECT - da.id, - da.org_id, - da.dashboard_id, - da.user_id, - da.team_id, - da.permission, - da.role, - da.created, - da.updated, - '' as user_login, - '' as user_email, - '' as team, - folder.title, - folder.slug, - folder.uid, - folder.is_folder - FROM dashboard_acl as da, - dashboard as dash - LEFT OUTER JOIN dashboard folder on dash.folder_id = folder.id - WHERE - dash.id = ? AND ( - dash.has_acl = ` + dialect.BooleanStr(false) + ` or - folder.has_acl = ` + dialect.BooleanStr(false) + ` - ) AND - da.dashboard_id = -1 + FROM dashboard as d + LEFT JOIN dashboard folder on folder.id = d.folder_id + LEFT JOIN dashboard_acl AS da ON + da.dashboard_id = d.id OR + da.dashboard_id = d.folder_id OR + ( + -- include default permissions --> + da.org_id = -1 AND ( + (folder.id IS NOT NULL AND folder.has_acl = ` + falseStr + `) OR + (folder.id IS NULL AND d.has_acl = ` + falseStr + `) + ) + ) + LEFT JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id + LEFT JOIN team ug on ug.id = da.team_id + WHERE d.org_id = ? AND d.id = ? AND da.id IS NOT NULL ORDER BY 1 ASC ` diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/sqlstore/dashboard_acl_test.go index 8b712c73ece..8fbb9c0d813 100644 --- a/pkg/services/sqlstore/dashboard_acl_test.go +++ b/pkg/services/sqlstore/dashboard_acl_test.go @@ -17,7 +17,7 @@ func TestDashboardAclDataAccess(t *testing.T) { childDash := insertTestDashboard("2 test dash", 1, savedFolder.Id, false, "prod", "webapp") Convey("When adding dashboard permission with userId and teamId set to 0", func() { - err := SetDashboardAcl(&m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(savedFolder.Id, m.DashboardAcl{ OrgId: 1, DashboardId: savedFolder.Id, Permission: m.PERMISSION_EDIT, @@ -41,8 +41,25 @@ func TestDashboardAclDataAccess(t *testing.T) { }) }) + Convey("Given dashboard folder with removed default permissions", func() { + err := UpdateDashboardAcl(&m.UpdateDashboardAclCommand{ + DashboardId: savedFolder.Id, + Items: []*m.DashboardAcl{}, + }) + So(err, ShouldBeNil) + + Convey("When reading dashboard acl should return no acl items", func() { + query := m.GetDashboardAclInfoListQuery{DashboardId: childDash.Id, OrgId: 1} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 0) + }) + }) + Convey("Given dashboard folder permission", func() { - err := SetDashboardAcl(&m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(savedFolder.Id, m.DashboardAcl{ OrgId: 1, UserId: currentUser.Id, DashboardId: savedFolder.Id, @@ -61,7 +78,7 @@ func TestDashboardAclDataAccess(t *testing.T) { }) Convey("Given child dashboard permission", func() { - err := SetDashboardAcl(&m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(childDash.Id, m.DashboardAcl{ OrgId: 1, UserId: currentUser.Id, DashboardId: childDash.Id, @@ -83,7 +100,7 @@ func TestDashboardAclDataAccess(t *testing.T) { }) Convey("Given child dashboard permission in folder with no permissions", func() { - err := SetDashboardAcl(&m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(childDash.Id, m.DashboardAcl{ OrgId: 1, UserId: currentUser.Id, DashboardId: childDash.Id, @@ -108,17 +125,12 @@ func TestDashboardAclDataAccess(t *testing.T) { }) Convey("Should be able to add dashboard permission", func() { - setDashAclCmd := m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(savedFolder.Id, m.DashboardAcl{ OrgId: 1, UserId: currentUser.Id, DashboardId: savedFolder.Id, Permission: m.PERMISSION_EDIT, - } - - err := SetDashboardAcl(&setDashAclCmd) - So(err, ShouldBeNil) - - So(setDashAclCmd.Result.Id, ShouldEqual, 3) + }) q1 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} err = GetDashboardAclInfoList(q1) @@ -130,42 +142,9 @@ func TestDashboardAclDataAccess(t *testing.T) { So(q1.Result[0].UserId, ShouldEqual, currentUser.Id) So(q1.Result[0].UserLogin, ShouldEqual, currentUser.Login) So(q1.Result[0].UserEmail, ShouldEqual, currentUser.Email) - So(q1.Result[0].Id, ShouldEqual, setDashAclCmd.Result.Id) - - Convey("Should update hasAcl field to true for dashboard folder and its children", func() { - q2 := &m.GetDashboardsQuery{DashboardIds: []int64{savedFolder.Id, childDash.Id}} - err := GetDashboards(q2) - So(err, ShouldBeNil) - So(q2.Result[0].HasAcl, ShouldBeTrue) - So(q2.Result[1].HasAcl, ShouldBeTrue) - }) - - Convey("Should be able to update an existing permission", func() { - err := SetDashboardAcl(&m.SetDashboardAclCommand{ - OrgId: 1, - UserId: 1, - DashboardId: savedFolder.Id, - Permission: m.PERMISSION_ADMIN, - }) - - So(err, ShouldBeNil) - - q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} - err = GetDashboardAclInfoList(q3) - So(err, ShouldBeNil) - 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].UserId, ShouldEqual, 1) - - }) Convey("Should be able to delete an existing permission", func() { - err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{ - OrgId: 1, - AclId: setDashAclCmd.Result.Id, - }) - + err := testHelperUpdateDashboardAcl(savedFolder.Id) So(err, ShouldBeNil) q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} @@ -181,14 +160,12 @@ func TestDashboardAclDataAccess(t *testing.T) { So(err, ShouldBeNil) Convey("Should be able to add a user permission for a team", func() { - setDashAclCmd := m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(savedFolder.Id, m.DashboardAcl{ OrgId: 1, TeamId: group1.Result.Id, DashboardId: savedFolder.Id, Permission: m.PERMISSION_EDIT, - } - - err := SetDashboardAcl(&setDashAclCmd) + }) So(err, ShouldBeNil) q1 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} @@ -197,23 +174,10 @@ func TestDashboardAclDataAccess(t *testing.T) { So(q1.Result[0].DashboardId, ShouldEqual, savedFolder.Id) So(q1.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT) So(q1.Result[0].TeamId, ShouldEqual, group1.Result.Id) - - Convey("Should be able to delete an existing permission for a team", func() { - err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{ - OrgId: 1, - AclId: setDashAclCmd.Result.Id, - }) - - So(err, ShouldBeNil) - q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1} - err = GetDashboardAclInfoList(q3) - So(err, ShouldBeNil) - So(len(q3.Result), ShouldEqual, 0) - }) }) Convey("Should be able to update an existing permission for a team", func() { - err := SetDashboardAcl(&m.SetDashboardAclCommand{ + err := testHelperUpdateDashboardAcl(savedFolder.Id, m.DashboardAcl{ OrgId: 1, TeamId: group1.Result.Id, DashboardId: savedFolder.Id, @@ -229,7 +193,6 @@ func TestDashboardAclDataAccess(t *testing.T) { So(q3.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) So(q3.Result[0].TeamId, ShouldEqual, group1.Result.Id) }) - }) }) diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go index 4818deaae14..40d6cf5bcb2 100644 --- a/pkg/services/sqlstore/dashboard_folder_test.go +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -26,7 +26,11 @@ func TestDashboardFolderDataAccess(t *testing.T) { Convey("and no acls are set", func() { Convey("should return all dashboards", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, + OrgId: 1, + DashboardIds: []int64{folder.Id, dashInRoot.Id}, + } err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -37,10 +41,13 @@ func TestDashboardFolderDataAccess(t *testing.T) { Convey("and acl is set for dashboard folder", func() { var otherUser int64 = 999 - updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) + testHelperUpdateDashboardAcl(folder.Id, m.DashboardAcl{DashboardId: folder.Id, OrgId: 1, UserId: otherUser, Permission: m.PERMISSION_EDIT}) Convey("should not return folder", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, + OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}, + } err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) @@ -48,10 +55,14 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("when the user is given permission", func() { - updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT) + testHelperUpdateDashboardAcl(folder.Id, m.DashboardAcl{DashboardId: folder.Id, OrgId: 1, UserId: currentUser.Id, Permission: m.PERMISSION_EDIT}) Convey("should be able to access folder", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, + OrgId: 1, + DashboardIds: []int64{folder.Id, dashInRoot.Id}, + } err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -82,12 +93,11 @@ func TestDashboardFolderDataAccess(t *testing.T) { Convey("and acl is set for dashboard child and folder has all permissions removed", func() { var otherUser int64 = 999 - aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) - removeAcl(aclId) - updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT) + testHelperUpdateDashboardAcl(folder.Id) + testHelperUpdateDashboardAcl(childDash.Id, m.DashboardAcl{DashboardId: folder.Id, OrgId: 1, UserId: otherUser, Permission: m.PERMISSION_EDIT}) Convey("should not return folder or child", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) @@ -95,10 +105,10 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("when the user is given permission to child", func() { - updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT) + testHelperUpdateDashboardAcl(childDash.Id, m.DashboardAcl{DashboardId: childDash.Id, OrgId: 1, UserId: currentUser.Id, Permission: m.PERMISSION_EDIT}) Convey("should be able to search for child dashboard but not folder", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -141,7 +151,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { Convey("and one folder is expanded, the other collapsed", func() { Convey("should return dashboards in root and expanded folder", func() { - query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1} + query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, OrgId: 1} err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 4) @@ -154,15 +164,14 @@ func TestDashboardFolderDataAccess(t *testing.T) { Convey("and acl is set for one dashboard folder", func() { var otherUser int64 = 999 - updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT) + testHelperUpdateDashboardAcl(folder1.Id, m.DashboardAcl{DashboardId: folder1.Id, OrgId: 1, UserId: otherUser, Permission: m.PERMISSION_EDIT}) Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() { - movedDash := moveDashboard(1, childDash2.Data, folder1.Id) - So(movedDash.HasAcl, ShouldBeTrue) + moveDashboard(1, childDash2.Data, folder1.Id) Convey("should not return folder with acl or its children", func() { query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, OrgId: 1, DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, } @@ -172,14 +181,12 @@ func TestDashboardFolderDataAccess(t *testing.T) { So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) }) }) - Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() { - movedDash := moveDashboard(1, childDash1.Data, folder2.Id) - So(movedDash.HasAcl, ShouldBeFalse) + moveDashboard(1, childDash1.Data, folder2.Id) Convey("should return folder without acl and its children", func() { query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, OrgId: 1, DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, } @@ -194,22 +201,22 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("and a dashboard with an acl is moved to the folder without an acl", func() { - updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT) - movedDash := moveDashboard(1, childDash1.Data, folder2.Id) - So(movedDash.HasAcl, ShouldBeTrue) + testHelperUpdateDashboardAcl(childDash1.Id, m.DashboardAcl{DashboardId: childDash1.Id, OrgId: 1, UserId: otherUser, Permission: m.PERMISSION_EDIT}) + moveDashboard(1, childDash1.Data, folder2.Id) Convey("should return folder without acl but not the dashboard with acl", func() { query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1, OrgRole: m.ROLE_VIEWER}, OrgId: 1, DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, } err := SearchDashboards(query) So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 3) + So(len(query.Result), ShouldEqual, 4) So(query.Result[0].Id, ShouldEqual, folder2.Id) - So(query.Result[1].Id, ShouldEqual, childDash2.Id) - So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[1].Id, ShouldEqual, childDash1.Id) + So(query.Result[2].Id, ShouldEqual, childDash2.Id) + So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) }) }) }) @@ -227,12 +234,14 @@ func TestDashboardFolderDataAccess(t *testing.T) { Convey("Admin users", func() { Convey("Should have write access to all dashboard folders in their org", func() { - query := m.GetFoldersForSignedInUserQuery{ + query := search.FindPersistedDashboardsQuery{ OrgId: 1, - SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN}, + SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN, OrgId: 1}, + Permission: m.PERMISSION_VIEW, + Type: "dash-folder", } - err := GetFoldersForSignedInUser(&query) + err := SearchDashboards(&query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -260,13 +269,14 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("Editor users", func() { - query := m.GetFoldersForSignedInUserQuery{ + query := search.FindPersistedDashboardsQuery{ OrgId: 1, - SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR}, + SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR, OrgId: 1}, + Permission: m.PERMISSION_EDIT, } Convey("Should have write access to all dashboard folders with default ACL", func() { - err := GetFoldersForSignedInUser(&query) + err := SearchDashboards(&query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -293,9 +303,9 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() { - updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW) + testHelperUpdateDashboardAcl(folder1.Id, m.DashboardAcl{DashboardId: folder1.Id, OrgId: 1, UserId: editorUser.Id, Permission: m.PERMISSION_VIEW}) - err := GetFoldersForSignedInUser(&query) + err := SearchDashboards(&query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) @@ -305,13 +315,14 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("Viewer users", func() { - query := m.GetFoldersForSignedInUserQuery{ + query := search.FindPersistedDashboardsQuery{ OrgId: 1, - SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER}, + SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER, OrgId: 1}, + Permission: m.PERMISSION_EDIT, } Convey("Should have no write access to any dashboard folders with default ACL", func() { - err := GetFoldersForSignedInUser(&query) + err := SearchDashboards(&query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 0) @@ -336,9 +347,9 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() { - updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT) + testHelperUpdateDashboardAcl(folder1.Id, m.DashboardAcl{DashboardId: folder1.Id, OrgId: 1, UserId: viewerUser.Id, Permission: m.PERMISSION_EDIT}) - err := GetFoldersForSignedInUser(&query) + err := SearchDashboards(&query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go new file mode 100644 index 00000000000..69409c3b873 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -0,0 +1,66 @@ +package sqlstore + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", GetProvisionedDashboardDataQuery) + bus.AddHandler("sql", SaveProvisionedDashboard) +} + +type DashboardExtras struct { + Id int64 + DashboardId int64 + Key string + Value string +} + +func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error { + return inTransaction(func(sess *DBSession) error { + err := saveDashboard(sess, cmd.DashboardCmd) + + if err != nil { + return err + } + + cmd.Result = cmd.DashboardCmd.Result + if cmd.DashboardProvisioning.Updated == 0 { + cmd.DashboardProvisioning.Updated = cmd.Result.Updated.Unix() + } + + return saveProvionedData(sess, cmd.DashboardProvisioning, cmd.Result) + }) +} + +func saveProvionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error { + result := &models.DashboardProvisioning{} + + exist, err := sess.Where("dashboard_id=?", dashboard.Id).Get(result) + if err != nil { + return err + } + + cmd.Id = result.Id + cmd.DashboardId = dashboard.Id + + if exist { + _, err = sess.ID(result.Id).Update(cmd) + } else { + _, err = sess.Insert(cmd) + } + + return err +} + +func GetProvisionedDashboardDataQuery(cmd *models.GetProvisionedDashboardDataQuery) error { + var result []*models.DashboardProvisioning + + if err := x.Where("name = ?", cmd.Name).Find(&result); err != nil { + return err + } + + cmd.Result = result + return nil +} diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go new file mode 100644 index 00000000000..b752173b67d --- /dev/null +++ b/pkg/services/sqlstore/dashboard_provisioning_test.go @@ -0,0 +1,55 @@ +package sqlstore + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardProvisioningTest(t *testing.T) { + Convey("Testing Dashboard provisioning", t, func() { + InitTestDB(t) + + saveDashboardCmd := &models.SaveDashboardCommand{ + OrgId: 1, + FolderId: 0, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dashboard", + }), + } + + Convey("Saving dashboards with extras", func() { + now := time.Now() + + cmd := &models.SaveProvisionedDashboardCommand{ + DashboardCmd: saveDashboardCmd, + DashboardProvisioning: &models.DashboardProvisioning{ + Name: "default", + ExternalId: "/var/grafana.json", + Updated: now.Unix(), + }, + } + + err := SaveProvisionedDashboard(cmd) + So(err, ShouldBeNil) + So(cmd.Result, ShouldNotBeNil) + So(cmd.Result.Id, ShouldNotEqual, 0) + dashId := cmd.Result.Id + + Convey("Can query for provisioned dashboards", func() { + query := &models.GetProvisionedDashboardDataQuery{Name: "default"} + err := GetProvisionedDashboardDataQuery(query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].DashboardId, ShouldEqual, dashId) + So(query.Result[0].Updated, ShouldEqual, now.Unix()) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard_service_integration_test.go b/pkg/services/sqlstore/dashboard_service_integration_test.go new file mode 100644 index 00000000000..e4bf93c2f58 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_service_integration_test.go @@ -0,0 +1,984 @@ +package sqlstore + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestIntegratedDashboardService(t *testing.T) { + Convey("Dashboard service integration tests", t, func() { + InitTestDB(t) + var testOrgId int64 = 1 + + Convey("Given saved folders and dashboards in organization A", func() { + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + savedFolder := saveTestFolder("Saved folder", testOrgId) + savedDashInFolder := saveTestDashboard("Saved dash in folder", testOrgId, savedFolder.Id) + saveTestDashboard("Other saved dash in folder", testOrgId, savedFolder.Id) + savedDashInGeneralFolder := saveTestDashboard("Saved dashboard in general folder", testOrgId, 0) + otherSavedFolder := saveTestFolder("Other saved folder", testOrgId) + + Convey("Should return dashboard model", func() { + So(savedFolder.Title, ShouldEqual, "Saved folder") + So(savedFolder.Slug, ShouldEqual, "saved-folder") + So(savedFolder.Id, ShouldNotEqual, 0) + So(savedFolder.IsFolder, ShouldBeTrue) + So(savedFolder.FolderId, ShouldEqual, 0) + So(len(savedFolder.Uid), ShouldBeGreaterThan, 0) + + So(savedDashInFolder.Title, ShouldEqual, "Saved dash in folder") + So(savedDashInFolder.Slug, ShouldEqual, "saved-dash-in-folder") + So(savedDashInFolder.Id, ShouldNotEqual, 0) + So(savedDashInFolder.IsFolder, ShouldBeFalse) + So(savedDashInFolder.FolderId, ShouldEqual, savedFolder.Id) + So(len(savedDashInFolder.Uid), ShouldBeGreaterThan, 0) + }) + + // Basic validation tests + + Convey("When saving a dashboard with non-existing id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": float64(123412321), + "title": "Expect error", + }), + } + + err := callSaveWithError(cmd) + + Convey("It should result in not found error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardNotFound) + }) + }) + + // Given other organization + + Convey("Given organization B", func() { + var otherOrgId int64 = 2 + + Convey("When saving a dashboard with id that are saved in organization A", func() { + cmd := models.SaveDashboardCommand{ + OrgId: otherOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "Expect error", + }), + Overwrite: false, + } + + err := callSaveWithError(cmd) + + Convey("It should result in not found error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardNotFound) + }) + }) + + permissionScenario("Given user has permission to save", true, func(sc *dashboardPermissionScenarioContext) { + Convey("When saving a dashboard with uid that are saved in organization A", func() { + var otherOrgId int64 = 2 + cmd := models.SaveDashboardCommand{ + OrgId: otherOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Dash with existing uid in other org", + }), + Overwrite: false, + } + + res := callSaveWithResult(cmd) + + Convey("It should create dashboard in other organization", func() { + So(res, ShouldNotBeNil) + + query := models.GetDashboardQuery{OrgId: otherOrgId, Uid: savedDashInFolder.Uid} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldNotEqual, savedDashInFolder.Id) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.OrgId, ShouldEqual, otherOrgId) + So(query.Result.Uid, ShouldEqual, savedDashInFolder.Uid) + }) + }) + }) + }) + + // Given user has no permission to save + + permissionScenario("Given user has no permission to save", false, func(sc *dashboardPermissionScenarioContext) { + + Convey("When trying to create a new dashboard in the General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.dashId, ShouldEqual, 0) + So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When trying to create a new dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + FolderId: otherSavedFolder.Id, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and rsult in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.dashId, ShouldEqual, otherSavedFolder.Id) + So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When trying to update a dashboard by existing id in the General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Dash", + }), + FolderId: savedDashInGeneralFolder.FolderId, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInGeneralFolder.Id) + So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When trying to update a dashboard by existing id in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "Dash", + }), + FolderId: savedDashInFolder.FolderId, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInFolder.Id) + So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + }) + }) + }) + + // Given user has permission to save + + permissionScenario("Given user has permission to save", true, func(sc *dashboardPermissionScenarioContext) { + + Convey("and overwrite flag is set to false", func() { + shouldOverwrite := false + + Convey("When creating a dashboard in General folder with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.FolderId, ShouldEqual, 0) + }) + }) + + Convey("When creating a dashboard in other folder with same name as dashboard in General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInGeneralFolder.Title, + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + So(res.Id, ShouldNotEqual, savedDashInGeneralFolder.Id) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.FolderId, ShouldEqual, savedFolder.Id) + }) + }) + + Convey("When creating a folder with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new folder", func() { + So(res.Id, ShouldNotEqual, savedDashInGeneralFolder.Id) + So(res.IsFolder, ShouldBeTrue) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.IsFolder, ShouldBeTrue) + }) + }) + + Convey("When saving a dashboard without id and uid and unique title in folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash without id and uid", + }), + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + So(res.Id, ShouldBeGreaterThan, 0) + So(len(res.Uid), ShouldBeGreaterThan, 0) + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.Uid, ShouldEqual, res.Uid) + }) + }) + + Convey("When saving a dashboard when dashboard id is zero ", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": 0, + "title": "Dash with zero id", + }), + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + }) + }) + + Convey("When saving a dashboard in non-existing folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Expect error", + }), + FolderId: 123412321, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in folder not found error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderNotFound) + }) + }) + + Convey("When updating an existing dashboard by id without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "test dash 23", + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in version mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardVersionMismatch) + }) + }) + + Convey("When updating an existing dashboard by id with current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Updated title", + "version": savedDashInGeneralFolder.Version, + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInGeneralFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, savedFolder.Id) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInGeneralFolder.Version) + }) + }) + + Convey("When updating an existing dashboard by uid without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "test dash 23", + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in version mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardVersionMismatch) + }) + }) + + Convey("When updating an existing dashboard by uid with current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Updated title", + "version": savedDashInFolder.Version, + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInFolder.Version) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + FolderId: savedDashInFolder.FolderId, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name in folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameInFolderExists) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInGeneralFolder.Title, + }), + FolderId: savedDashInGeneralFolder.FolderId, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name in folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameInFolderExists) + }) + }) + + Convey("When creating a folder with same name as existing folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name in folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameInFolderExists) + }) + }) + }) + + Convey("and overwrite flag is set to true", func() { + shouldOverwrite := true + + Convey("When updating an existing dashboard by id without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Updated title", + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInGeneralFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, savedFolder.Id) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInGeneralFolder.Version) + }) + }) + + Convey("When updating an existing dashboard by uid without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Updated title", + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInFolder.Version) + }) + }) + + Convey("When updating uid for existing dashboard using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "uid": "new-uid", + "title": savedDashInFolder.Title, + }), + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + + Convey("It should update dashboard", func() { + So(res, ShouldNotBeNil) + So(res.Id, ShouldEqual, savedDashInFolder.Id) + So(res.Uid, ShouldEqual, "new-uid") + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Uid, ShouldEqual, "new-uid") + So(query.Result.Version, ShouldBeGreaterThan, savedDashInFolder.Version) + }) + }) + + Convey("When updating uid to an existing uid for existing dashboard using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "uid": savedDashInGeneralFolder.Uid, + "title": savedDashInFolder.Title, + }), + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in same uid exists error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameUIDExists) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + FolderId: savedDashInFolder.FolderId, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + + Convey("It should overwrite existing dashboard", func() { + So(res, ShouldNotBeNil) + So(res.Id, ShouldEqual, savedDashInFolder.Id) + So(res.Uid, ShouldEqual, savedDashInFolder.Uid) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.Uid, ShouldEqual, res.Uid) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInGeneralFolder.Title, + }), + FolderId: savedDashInGeneralFolder.FolderId, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + + Convey("It should overwrite existing dashboard", func() { + So(res, ShouldNotBeNil) + So(res.Id, ShouldEqual, savedDashInGeneralFolder.Id) + So(res.Uid, ShouldEqual, savedDashInGeneralFolder.Uid) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.Uid, ShouldEqual, res.Uid) + }) + }) + + Convey("When trying to update existing folder to a dashboard using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedFolder.Id, + "title": "new title", + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing dashboard to a folder using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing folder to a dashboard using uid", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedFolder.Uid, + "title": "new title", + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing dashboard to a folder using uid", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing folder to a dashboard using title", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedFolder.Title, + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name as folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameAsFolder) + }) + }) + + Convey("When trying to update existing dashboard to a folder using title", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedDashInGeneralFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in folder with same name as dashboard error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardFolderWithSameNameAsDashboard) + }) + }) + }) + }) + }) + }) +} + +func mockDashboardGuardian(mock *mockDashboardGuarder) { + guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian { + mock.orgId = orgId + mock.dashId = dashId + mock.user = user + return mock + } +} + +type mockDashboardGuarder struct { + dashId int64 + orgId int64 + user *models.SignedInUser + canSave bool + canSaveCallCounter int + canEdit bool + canView bool + canAdmin bool + hasPermission bool + checkPermissionBeforeRemove bool + checkPermissionBeforeUpdate bool +} + +func (g *mockDashboardGuarder) CanSave() (bool, error) { + g.canSaveCallCounter++ + return g.canSave, nil +} + +func (g *mockDashboardGuarder) CanEdit() (bool, error) { + return g.canEdit, nil +} + +func (g *mockDashboardGuarder) CanView() (bool, error) { + return g.canView, nil +} + +func (g *mockDashboardGuarder) CanAdmin() (bool, error) { + return g.canAdmin, nil +} + +func (g *mockDashboardGuarder) HasPermission(permission models.PermissionType) (bool, error) { + return g.hasPermission, nil +} + +func (g *mockDashboardGuarder) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) { + return g.checkPermissionBeforeUpdate, nil +} + +func (g *mockDashboardGuarder) GetAcl() ([]*models.DashboardAclInfoDTO, error) { + return nil, nil +} + +type scenarioContext struct { + dashboardGuardianMock *mockDashboardGuarder +} + +type scenarioFunc func(c *scenarioContext) + +func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) { + Convey(desc, func() { + origNewDashboardGuardian := guardian.New + mockDashboardGuardian(mock) + + sc := &scenarioContext{ + dashboardGuardianMock: mock, + } + + defer func() { + guardian.New = origNewDashboardGuardian + }() + + fn(sc) + }) +} + +type dashboardPermissionScenarioContext struct { + dashboardGuardianMock *mockDashboardGuarder +} + +type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext) + +func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn dashboardPermissionScenarioFunc) { + Convey(desc, func() { + origNewDashboardGuardian := guardian.New + mockDashboardGuardian(mock) + + sc := &dashboardPermissionScenarioContext{ + dashboardGuardianMock: mock, + } + + defer func() { + guardian.New = origNewDashboardGuardian + }() + + fn(sc) + }) +} + +func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) { + mock := &mockDashboardGuarder{ + canSave: canSave, + } + dashboardPermissionScenario(desc, mock, fn) +} + +func callSaveWithResult(cmd models.SaveDashboardCommand) *models.Dashboard { + dto := toSaveDashboardDto(cmd) + res, _ := dashboards.NewService().SaveDashboard(&dto) + return res +} + +func callSaveWithError(cmd models.SaveDashboardCommand) error { + dto := toSaveDashboardDto(cmd) + _, err := dashboards.NewService().SaveDashboard(&dto) + return err +} + +func dashboardServiceScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) { + Convey(desc, func() { + origNewDashboardGuardian := guardian.New + mockDashboardGuardian(mock) + + sc := &scenarioContext{ + dashboardGuardianMock: mock, + } + + defer func() { + guardian.New = origNewDashboardGuardian + }() + + fn(sc) + }) +} + +func saveTestDashboard(title string, orgId int64, folderId int64) *models.Dashboard { + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + }), + } + + dto := dashboards.SaveDashboardDTO{ + OrgId: orgId, + Dashboard: cmd.GetDashboardModel(), + User: &models.SignedInUser{ + UserId: 1, + OrgRole: models.ROLE_ADMIN, + }, + } + + res, err := dashboards.NewService().SaveDashboard(&dto) + So(err, ShouldBeNil) + + return res +} + +func saveTestFolder(title string, orgId int64) *models.Dashboard { + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: 0, + IsFolder: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + }), + } + + dto := dashboards.SaveDashboardDTO{ + OrgId: orgId, + Dashboard: cmd.GetDashboardModel(), + User: &models.SignedInUser{ + UserId: 1, + OrgRole: models.ROLE_ADMIN, + }, + } + + res, err := dashboards.NewService().SaveDashboard(&dto) + So(err, ShouldBeNil) + + return res +} + +func toSaveDashboardDto(cmd models.SaveDashboardCommand) dashboards.SaveDashboardDTO { + dash := (&cmd).GetDashboardModel() + + return dashboards.SaveDashboardDTO{ + Dashboard: dash, + Message: cmd.Message, + OrgId: cmd.OrgId, + User: &models.SignedInUser{UserId: cmd.UserId}, + Overwrite: cmd.Overwrite, + } +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index bd769d307eb..51a2d4eb64e 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -100,324 +100,6 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldBeNil) }) - Convey("Should return not found error if no dashboard is found for update", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Overwrite: true, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": float64(123412321), - "title": "Expect error", - "tags": []interface{}{}, - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardNotFound) - }) - - Convey("Should not be able to overwrite dashboard in another org", func() { - query := m.GetDashboardQuery{Slug: "test-dash-23", OrgId: 1} - GetDashboard(&query) - - cmd := m.SaveDashboardCommand{ - OrgId: 2, - Overwrite: true, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": float64(query.Result.Id), - "title": "Expect error", - "tags": []interface{}{}, - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardNotFound) - }) - - Convey("Should be able to save dashboards with same name in different folders", func() { - firstSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "randomHash", - }), - FolderId: 3, - } - - err := SaveDashboard(&firstSaveCmd) - So(err, ShouldBeNil) - - secondSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "moreRandomHash", - }), - FolderId: 1, - } - - err = SaveDashboard(&secondSaveCmd) - So(err, ShouldBeNil) - So(firstSaveCmd.Result.Id, ShouldNotEqual, secondSaveCmd.Result.Id) - }) - - Convey("Should be able to overwrite dashboard in same folder using title", func() { - insertTestDashboard("Dash", 1, 0, false, "prod", "webapp") - folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp") - dashInFolder := insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp") - - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": "Dash", - }), - FolderId: folder.Id, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - So(cmd.Result.Id, ShouldEqual, dashInFolder.Id) - So(cmd.Result.Uid, ShouldEqual, dashInFolder.Uid) - }) - - Convey("Should be able to overwrite dashboard in General folder using title", func() { - dashInGeneral := insertTestDashboard("Dash", 1, 0, false, "prod", "webapp") - folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp") - insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp") - - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": "Dash", - }), - FolderId: 0, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - So(cmd.Result.Id, ShouldEqual, dashInGeneral.Id) - So(cmd.Result.Uid, ShouldEqual, dashInGeneral.Uid) - }) - - Convey("Should not be able to overwrite folder with dashboard in general folder using title", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": savedFolder.Title, - }), - FolderId: 0, - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder) - }) - - Convey("Should not be able to overwrite folder with dashboard in folder using title", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": savedFolder.Title, - }), - FolderId: savedFolder.Id, - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder) - }) - - Convey("Should not be able to overwrite folder with dashboard using id", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": savedFolder.Id, - "title": "new title", - }), - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to overwrite dashboard with folder using id", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": savedDash.Id, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to overwrite folder with dashboard using uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedFolder.Uid, - "title": "new title", - }), - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to overwrite dashboard with folder using uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to save dashboard with same name in the same folder without overwrite", func() { - firstSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "randomHash", - }), - FolderId: 3, - } - - err := SaveDashboard(&firstSaveCmd) - So(err, ShouldBeNil) - - secondSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "moreRandomHash", - }), - FolderId: 3, - } - - err = SaveDashboard(&secondSaveCmd) - So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists) - }) - - Convey("Should be able to save and update dashboard using same uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "uid": "dsfalkjngailuedt", - "title": "test dash 23", - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - err = SaveDashboard(&cmd) - So(err, ShouldBeNil) - }) - - Convey("Should be able to update dashboard using uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "new title", - }), - FolderId: 0, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - - Convey("Should be able to get updated dashboard by uid", func() { - query := m.GetDashboardQuery{ - Uid: savedDash.Uid, - OrgId: 1, - } - - err := GetDashboard(&query) - So(err, ShouldBeNil) - - So(query.Result.Id, ShouldEqual, savedDash.Id) - So(query.Result.Title, ShouldEqual, "new title") - So(query.Result.FolderId, ShouldEqual, 0) - }) - }) - - Convey("Should be able to update dashboard with the same title and folder id", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": "randomHash", - "title": "folderId", - "style": "light", - "tags": []interface{}{}, - }), - FolderId: 2, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - So(cmd.Result.FolderId, ShouldEqual, 2) - - cmd = m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": cmd.Result.Id, - "uid": "randomHash", - "title": "folderId", - "style": "dark", - "version": cmd.Result.Version, - "tags": []interface{}{}, - }), - FolderId: 2, - } - - err = SaveDashboard(&cmd) - So(err, ShouldBeNil) - }) - - Convey("Should be able to update using uid without id and overwrite", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "folderId", - "version": savedDash.Version, - "tags": []interface{}{}, - }), - FolderId: savedDash.FolderId, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - }) - Convey("Should retry generation of uid once if it fails.", func() { timesCalled := 0 generateNewUid = func() string { @@ -499,6 +181,36 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 0) }) + Convey("Should return error if no dashboard is found for update when dashboard id is greater than zero", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Overwrite: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": float64(123412321), + "title": "Expect error", + "tags": []interface{}{}, + }), + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardNotFound) + }) + + Convey("Should not return error if no dashboard is found for update when dashboard id is zero", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Overwrite: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": 0, + "title": "New dash", + "tags": []interface{}{}, + }), + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + }) + Convey("Should be able to get dashboard tags", func() { query := m.GetDashboardTagsQuery{OrgId: 1} @@ -512,7 +224,7 @@ func TestDashboardDataAccess(t *testing.T) { query := search.FindPersistedDashboardsQuery{ Title: "1 test dash folder", OrgId: 1, - SignedInUser: &m.SignedInUser{OrgId: 1}, + SignedInUser: &m.SignedInUser{OrgId: 1, OrgRole: m.ROLE_EDITOR}, } err := SearchDashboards(&query) @@ -529,7 +241,7 @@ func TestDashboardDataAccess(t *testing.T) { query := search.FindPersistedDashboardsQuery{ OrgId: 1, FolderIds: []int64{savedFolder.Id}, - SignedInUser: &m.SignedInUser{OrgId: 1}, + SignedInUser: &m.SignedInUser{OrgId: 1, OrgRole: m.ROLE_EDITOR}, } err := SearchDashboards(&query) @@ -549,7 +261,7 @@ func TestDashboardDataAccess(t *testing.T) { Convey("should be able to find two dashboards by id", func() { query := search.FindPersistedDashboardsQuery{ DashboardIds: []int64{2, 3}, - SignedInUser: &m.SignedInUser{OrgId: 1}, + SignedInUser: &m.SignedInUser{OrgId: 1, OrgRole: m.ROLE_EDITOR}, } err := SearchDashboards(&query) @@ -578,7 +290,10 @@ func TestDashboardDataAccess(t *testing.T) { }) Convey("Should be able to search for starred dashboards", func() { - query := search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: 10, OrgId: 1}, IsStarred: true} + query := search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: 10, OrgId: 1, OrgRole: m.ROLE_EDITOR}, + IsStarred: true, + } err := SearchDashboards(&query) So(err, ShouldBeNil) @@ -624,6 +339,9 @@ func insertTestDashboard(title string, orgId int64, folderId int64, isFolder boo err := SaveDashboard(&cmd) So(err, ShouldBeNil) + cmd.Result.Data.Set("id", cmd.Result.Id) + cmd.Result.Data.Set("uid", cmd.Result.Uid) + return cmd.Result } @@ -660,25 +378,6 @@ func createUser(name string, role string, isAdmin bool) m.User { return currentUserCmd.Result } -func updateTestDashboardWithAcl(dashId int64, userId int64, permissions m.PermissionType) int64 { - cmd := &m.SetDashboardAclCommand{ - OrgId: 1, - UserId: userId, - DashboardId: dashId, - Permission: permissions, - } - - err := SetDashboardAcl(cmd) - So(err, ShouldBeNil) - - return cmd.Result.Id -} - -func removeAcl(aclId int64) { - err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{AclId: aclId, OrgId: 1}) - So(err, ShouldBeNil) -} - func moveDashboard(orgId int64, dashboard *simplejson.Json, newFolderId int64) *m.Dashboard { cmd := m.SaveDashboardCommand{ OrgId: orgId, diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index e20ac897b3d..1b74e7847c4 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -12,7 +12,7 @@ import ( ) func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) { - data["uid"] = dashboard.Uid + data["id"] = dashboard.Id saveCmd := m.SaveDashboardCommand{ OrgId: dashboard.OrgId, diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index e9b400a1772..00d520bcfc6 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -27,6 +27,9 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error { datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id} has, err := x.Get(&datasource) + if err != nil { + return err + } if !has { return m.ErrDataSourceNotFound diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index e6f0114ab4d..90300e20029 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -3,34 +3,11 @@ package sqlstore import ( "testing" - "github.com/go-xorm/xorm" - . "github.com/smartystreets/goconvey/convey" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" ) -func InitTestDB(t *testing.T) *xorm.Engine { - x, err := xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr) - //x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr) - //x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) - - // x.ShowSQL() - - if err != nil { - t.Fatalf("Failed to init in memory sqllite3 db %v", err) - } - - sqlutil.CleanDB(x) - - if err := SetEngine(x); err != nil { - t.Fatal(err) - } - - return x -} - type Test struct { Id int64 Name string diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go index 805d726df48..78da198e8e7 100644 --- a/pkg/services/sqlstore/login_attempt.go +++ b/pkg/services/sqlstore/login_attempt.go @@ -21,7 +21,7 @@ func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { loginAttempt := m.LoginAttempt{ Username: cmd.Username, IpAddress: cmd.IpAddress, - Created: getTimeNow(), + Created: getTimeNow().Unix(), } if _, err := sess.Insert(&loginAttempt); err != nil { @@ -37,8 +37,8 @@ func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error { return inTransaction(func(sess *DBSession) error { var maxId int64 - sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?") - result, err := sess.Query(sql, cmd.OlderThan) + sql := "SELECT max(id) as id FROM login_attempt WHERE created < ?" + result, err := sess.Query(sql, cmd.OlderThan.Unix()) if err != nil { return err @@ -66,7 +66,7 @@ func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error { loginAttempt := new(m.LoginAttempt) total, err := x. Where("username = ?", query.Username). - And("created >="+dialect.DateTimeFunc("?"), query.Since). + And("created >= ?", query.Since.Unix()). Count(loginAttempt) if err != nil { diff --git a/pkg/services/sqlstore/migrations/common.go b/pkg/services/sqlstore/migrations/common.go index cf0b39f1a35..cc31b1d4580 100644 --- a/pkg/services/sqlstore/migrations/common.go +++ b/pkg/services/sqlstore/migrations/common.go @@ -24,3 +24,24 @@ func addTableRenameMigration(mg *Migrator, oldName string, newName string, versi migrationId := fmt.Sprintf("Rename table %s to %s - %s", oldName, newName, versionSuffix) mg.AddMigration(migrationId, NewRenameTableMigration(oldName, newName)) } + +func addTableReplaceMigrations(mg *Migrator, from Table, to Table, migrationVersion int64, tableDataMigration map[string]string) { + fromV := version(migrationVersion - 1) + toV := version(migrationVersion) + tmpTableName := to.Name + "_tmp_qwerty" + + createTable := fmt.Sprintf("create %v %v", to.Name, toV) + copyTableData := fmt.Sprintf("copy %v %v to %v", to.Name, fromV, toV) + dropTable := fmt.Sprintf("drop %v", tmpTableName) + + addDropAllIndicesMigrations(mg, fromV, from) + addTableRenameMigration(mg, from.Name, tmpTableName, fromV) + mg.AddMigration(createTable, NewAddTableMigration(to)) + addTableIndicesMigrations(mg, toV, to) + mg.AddMigration(copyTableData, NewCopyTableDataMigration(to.Name, tmpTableName, tableDataMigration)) + mg.AddMigration(dropTable, NewDropTableMigration(tmpTableName)) +} + +func version(v int64) string { + return fmt.Sprintf("v%v", v) +} diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index c87a2906652..296950ee497 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -1,6 +1,8 @@ package migrations -import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) func addDashboardMigration(mg *Migrator) { var dashboardV1 = Table{ @@ -176,4 +178,39 @@ func addDashboardMigration(mg *Migrator) { Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex, })) + dashboardExtrasTable := Table{ + Name: "dashboard_provisioning", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "dashboard_id", Type: DB_BigInt, Nullable: true}, + {Name: "name", Type: DB_NVarchar, Length: 150, Nullable: false}, + {Name: "external_id", Type: DB_Text, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{}, + } + + mg.AddMigration("create dashboard_provisioning", NewAddTableMigration(dashboardExtrasTable)) + + dashboardExtrasTableV2 := Table{ + Name: "dashboard_provisioning", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "dashboard_id", Type: DB_BigInt, Nullable: true}, + {Name: "name", Type: DB_NVarchar, Length: 150, Nullable: false}, + {Name: "external_id", Type: DB_Text, Nullable: false}, + {Name: "updated", Type: DB_Int, Default: "0", Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"dashboard_id"}}, + {Cols: []string{"dashboard_id", "name"}, Type: IndexType}, + }, + } + + addTableReplaceMigrations(mg, dashboardExtrasTable, dashboardExtrasTableV2, 2, map[string]string{ + "id": "id", + "dashboard_id": "dashboard_id", + "name": "name", + "external_id": "external_id", + }) } diff --git a/pkg/services/sqlstore/migrations/login_attempt_mig.go b/pkg/services/sqlstore/migrations/login_attempt_mig.go index e576ccd1a50..df14eb4effa 100644 --- a/pkg/services/sqlstore/migrations/login_attempt_mig.go +++ b/pkg/services/sqlstore/migrations/login_attempt_mig.go @@ -20,4 +20,23 @@ func addLoginAttemptMigrations(mg *Migrator) { mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1)) // add indices mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0])) + + loginAttemptV2 := Table{ + Name: "login_attempt", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false}, + {Name: "created", Type: DB_Int, Default: "0", Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"username"}}, + }, + } + + addTableReplaceMigrations(mg, loginAttemptV1, loginAttemptV2, 2, map[string]string{ + "id": "id", + "username": "username", + "ip_address": "ip_address", + }) } diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 5bddf6ff605..51aea0bbdef 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -14,13 +14,15 @@ import ( var indexTypes = []string{"Unknown", "INDEX", "UNIQUE INDEX"} func TestMigrations(t *testing.T) { - //log.NewLogger(0, "console", `{"level": 0}`) - testDBs := []sqlutil.TestDB{ sqlutil.TestDB_Sqlite3, } for _, testDB := range testDBs { + sql := `select count(*) as count from migration_log` + r := struct { + Count int64 + }{} Convey("Initial "+testDB.DriverName+" migration", t, func() { x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) @@ -28,30 +30,31 @@ func TestMigrations(t *testing.T) { sqlutil.CleanDB(x) + has, err := x.SQL(sql).Get(&r) + So(err, ShouldNotBeNil) + mg := NewMigrator(x) AddMigrations(mg) err = mg.Start() So(err, ShouldBeNil) - // tables, err := x.DBMetas() - // So(err, ShouldBeNil) - // - // fmt.Printf("\nDB Schema after migration: table count: %v\n", len(tables)) - // - // for _, table := range tables { - // fmt.Printf("\nTable: %v \n", table.Name) - // for _, column := range table.Columns() { - // fmt.Printf("\t %v \n", column.String(x.Dialect())) - // } - // - // if len(table.Indexes) > 0 { - // fmt.Printf("\n\tIndexes:\n") - // for _, index := range table.Indexes { - // fmt.Printf("\t %v (%v) %v \n", index.Name, strings.Join(index.Cols, ","), indexTypes[index.Type]) - // } - // } - // } + has, err = x.SQL(sql).Get(&r) + So(err, ShouldBeNil) + So(has, ShouldBeTrue) + expectedMigrations := mg.MigrationsCount() - 2 //we currently skip to migrations. We should rewrite skipped migrations to write in the log as well. until then we have to keep this + So(r.Count, ShouldEqual, expectedMigrations) + + mg = NewMigrator(x) + AddMigrations(mg) + + err = mg.Start() + So(err, ShouldBeNil) + + has, err = x.SQL(sql).Get(&r) + So(err, ShouldBeNil) + So(has, ShouldBeTrue) + So(r.Count, ShouldEqual, expectedMigrations) }) } } diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index 64831ee46b4..a8bd36ac8a3 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -35,6 +35,10 @@ func NewMigrator(engine *xorm.Engine) *Migrator { return mg } +func (mg *Migrator) MigrationsCount() int { + return len(mg.migrations) +} + func (mg *Migrator) AddMigration(id string, m Migration) { m.SetId(id) mg.migrations = append(mg.migrations, m) diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 59d96c4f8ca..c57d15a48d5 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -123,6 +123,31 @@ func TestAccountDataAccess(t *testing.T) { So(query.Result[0].Role, ShouldEqual, "Admin") }) + Convey("Can get organization users with query", func() { + query := m.GetOrgUsersQuery{ + OrgId: ac1.OrgId, + Query: "ac1", + } + err := GetOrgUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Email, ShouldEqual, ac1.Email) + }) + + Convey("Can get organization users with query and limit", func() { + query := m.GetOrgUsersQuery{ + OrgId: ac1.OrgId, + Query: "ac", + Limit: 1, + } + err := GetOrgUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Email, ShouldEqual, ac1.Email) + }) + Convey("Can set using org", func() { cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.Id} err := SetUsingOrg(&cmd) @@ -174,10 +199,13 @@ func TestAccountDataAccess(t *testing.T) { So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 3) - err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: ac1.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT}) + dash1 := insertTestDashboard("1 test dash", ac1.OrgId, 0, false, "prod", "webapp") + dash2 := insertTestDashboard("2 test dash", ac3.OrgId, 0, false, "prod", "webapp") + + err = testHelperUpdateDashboardAcl(dash1.Id, m.DashboardAcl{DashboardId: dash1.Id, OrgId: ac1.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT}) So(err, ShouldBeNil) - err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 2, OrgId: ac3.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT}) + err = testHelperUpdateDashboardAcl(dash2.Id, m.DashboardAcl{DashboardId: dash2.Id, OrgId: ac3.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT}) So(err, ShouldBeNil) Convey("When org user is deleted", func() { @@ -209,3 +237,11 @@ func TestAccountDataAccess(t *testing.T) { }) }) } + +func testHelperUpdateDashboardAcl(dashboardId int64, items ...m.DashboardAcl) error { + cmd := m.UpdateDashboardAclCommand{DashboardId: dashboardId} + for _, item := range items { + cmd.Items = append(cmd.Items, &item) + } + return UpdateDashboardAcl(&cmd) +} diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 2c2a51fd362..0b991c73c55 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -2,6 +2,7 @@ package sqlstore import ( "fmt" + "strings" "time" "github.com/grafana/grafana/pkg/bus" @@ -69,9 +70,30 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error { func GetOrgUsers(query *m.GetOrgUsersQuery) error { query.Result = make([]*m.OrgUserDTO, 0) + sess := x.Table("org_user") sess.Join("INNER", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) - sess.Where("org_user.org_id=?", query.OrgId) + + whereConditions := make([]string, 0) + whereParams := make([]interface{}, 0) + + whereConditions = append(whereConditions, "org_user.org_id = ?") + whereParams = append(whereParams, query.OrgId) + + if query.Query != "" { + queryWithWildcards := "%" + query.Query + "%" + whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)") + whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards) + } + + if len(whereConditions) > 0 { + sess.Where(strings.Join(whereConditions, " AND "), whereParams...) + } + + if query.Limit > 0 { + sess.Limit(query.Limit, 0) + } + sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role", "user.last_seen_at") sess.Asc("user.email", "user.login") diff --git a/pkg/services/sqlstore/playlist.go b/pkg/services/sqlstore/playlist.go index b33c1f54f92..67720cbadb8 100644 --- a/pkg/services/sqlstore/playlist.go +++ b/pkg/services/sqlstore/playlist.go @@ -1,8 +1,6 @@ package sqlstore import ( - "fmt" - "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" ) @@ -25,8 +23,6 @@ func CreatePlaylist(cmd *m.CreatePlaylistCommand) error { _, err := x.Insert(&playlist) - fmt.Printf("%v", playlist.Id) - playlistItems := make([]m.PlaylistItem, 0) for _, item := range cmd.Items { playlistItems = append(playlistItems, m.PlaylistItem{ diff --git a/pkg/services/sqlstore/search_builder.go b/pkg/services/sqlstore/search_builder.go index 627074d5453..ddfbfbfc551 100644 --- a/pkg/services/sqlstore/search_builder.go +++ b/pkg/services/sqlstore/search_builder.go @@ -1,7 +1,6 @@ package sqlstore import ( - "bytes" "strings" m "github.com/grafana/grafana/pkg/models" @@ -9,6 +8,7 @@ import ( // SearchBuilder is a builder/object mother that builds a dashboard search query type SearchBuilder struct { + SqlBuilder tags []string isStarred bool limit int @@ -18,14 +18,14 @@ type SearchBuilder struct { whereTypeFolder bool whereTypeDash bool whereFolderIds []int64 - sql bytes.Buffer - params []interface{} + permission m.PermissionType } -func NewSearchBuilder(signedInUser *m.SignedInUser, limit int) *SearchBuilder { +func NewSearchBuilder(signedInUser *m.SignedInUser, limit int, permission m.PermissionType) *SearchBuilder { searchBuilder := &SearchBuilder{ signedInUser: signedInUser, limit: limit, + permission: permission, } return searchBuilder @@ -153,10 +153,7 @@ func (sb *SearchBuilder) buildMainQuery() { sb.sql.WriteString(` WHERE `) sb.buildSearchWhereClause() - sb.sql.WriteString(` - LIMIT ?) as ids - INNER JOIN dashboard on ids.id = dashboard.id - `) + sb.sql.WriteString(` LIMIT ?) as ids INNER JOIN dashboard on ids.id = dashboard.id `) sb.params = append(sb.params, sb.limit) } @@ -176,23 +173,7 @@ func (sb *SearchBuilder) buildSearchWhereClause() { } } - if sb.signedInUser.OrgRole != m.ROLE_ADMIN { - allowedDashboardsSubQuery := ` AND (dashboard.has_acl = ` + dialect.BooleanStr(false) + ` OR dashboard.id in ( - 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 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 = ` + dialect.BooleanStr(true) + ` and - (da.user_id = ? or ugm.user_id = ? or ou.id is not null) - and d.org_id = ? - ) - )` - - sb.sql.WriteString(allowedDashboardsSubQuery) - sb.params = append(sb.params, sb.signedInUser.UserId, sb.signedInUser.UserId, sb.signedInUser.OrgId) - } + sb.writeDashboardPermissionFilter(sb.signedInUser, sb.permission) if len(sb.whereTitle) > 0 { sb.sql.WriteString(" AND dashboard.title " + dialect.LikeStr() + " ?") diff --git a/pkg/services/sqlstore/search_builder_test.go b/pkg/services/sqlstore/search_builder_test.go index 32ccbc583f5..e8b02c445ec 100644 --- a/pkg/services/sqlstore/search_builder_test.go +++ b/pkg/services/sqlstore/search_builder_test.go @@ -16,7 +16,8 @@ func TestSearchBuilder(t *testing.T) { OrgId: 1, UserId: 1, } - sb := NewSearchBuilder(signedInUser, 1000) + + sb := NewSearchBuilder(signedInUser, 1000, m.PERMISSION_VIEW) Convey("When building a normal search", func() { sql, params := sb.IsStarred().WithTitle("test").ToSql() diff --git a/pkg/services/sqlstore/sqlbuilder.go b/pkg/services/sqlstore/sqlbuilder.go new file mode 100644 index 00000000000..b42c7926203 --- /dev/null +++ b/pkg/services/sqlstore/sqlbuilder.go @@ -0,0 +1,75 @@ +package sqlstore + +import ( + "bytes" + "strings" + + m "github.com/grafana/grafana/pkg/models" +) + +type SqlBuilder struct { + sql bytes.Buffer + params []interface{} +} + +func (sb *SqlBuilder) Write(sql string, params ...interface{}) { + sb.sql.WriteString(sql) + + if len(params) > 0 { + sb.params = append(sb.params, params...) + } +} + +func (sb *SqlBuilder) GetSqlString() string { + return sb.sql.String() +} + +func (sb *SqlBuilder) AddParams(params ...interface{}) { + sb.params = append(sb.params, params...) +} + +func (sb *SqlBuilder) writeDashboardPermissionFilter(user *m.SignedInUser, permission m.PermissionType) { + + if user.OrgRole == m.ROLE_ADMIN { + return + } + + okRoles := []interface{}{user.OrgRole} + + if user.OrgRole == m.ROLE_EDITOR { + okRoles = append(okRoles, m.ROLE_VIEWER) + } + + falseStr := dialect.BooleanStr(false) + + sb.sql.WriteString(` AND + ( + dashboard.id IN ( + SELECT distinct d.id AS DashboardId + FROM dashboard AS d + LEFT JOIN dashboard folder on folder.id = d.folder_id + LEFT JOIN dashboard_acl AS da ON + da.dashboard_id = d.id OR + da.dashboard_id = d.folder_id OR + ( + -- include default permissions --> + da.org_id = -1 AND ( + (folder.id IS NOT NULL AND folder.has_acl = ` + falseStr + `) OR + (folder.id IS NULL AND d.has_acl = ` + falseStr + `) + ) + ) + LEFT JOIN team_member as ugm on ugm.team_id = da.team_id + WHERE + d.org_id = ? AND + da.permission >= ? AND + ( + da.user_id = ? OR + ugm.user_id = ? OR + da.role IN (?` + strings.Repeat(",?", len(okRoles)-1) + `) + ) + ) + )`) + + sb.params = append(sb.params, user.OrgId, permission, user.UserId, user.UserId) + sb.params = append(sb.params, okRoles...) +} diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 2655bf9c22e..5843c5c300b 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -7,14 +7,15 @@ import ( "path" "path/filepath" "strings" + "testing" "github.com/grafana/grafana/pkg/bus" "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/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/setting" "github.com/go-sql-driver/mysql" @@ -101,7 +102,6 @@ func SetEngine(engine *xorm.Engine) (err error) { // Init repo instances annotations.SetRepository(&SqlAnnotationRepo{}) - dashboards.SetRepository(&dashboards.DashboardRepository{}) return nil } @@ -216,3 +216,46 @@ func LoadConfig() { DbCfg.ServerCertName = sec.Key("server_cert_name").String() DbCfg.Path = sec.Key("path").MustString("data/grafana.db") } + +var ( + dbSqlite = "sqlite" + dbMySql = "mysql" + dbPostgres = "postgres" +) + +func InitTestDB(t *testing.T) *xorm.Engine { + selectedDb := dbSqlite + //selectedDb := dbMySql + //selectedDb := dbPostgres + + var x *xorm.Engine + var err error + + // environment variable present for test db? + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + selectedDb = db + } + + switch strings.ToLower(selectedDb) { + case dbMySql: + x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr) + case dbPostgres: + x, err = xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) + default: + x, err = xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr) + } + + // x.ShowSQL() + + if err != nil { + t.Fatalf("Failed to init in memory sqllite3 db %v", err) + } + + sqlutil.CleanDB(x) + + if err := SetEngine(x); err != nil { + t.Fatal(err) + } + + return x +} diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index a33872ed687..26a58811e0f 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -11,8 +11,8 @@ type TestDB struct { ConnStr string } -var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} -var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci&loc=Local"} +var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:"} +var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} func CleanDB(x *xorm.Engine) { diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 98bb1a36eb9..d238301c7ce 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -25,7 +25,7 @@ func init() { func CreateTeam(cmd *m.CreateTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if isNameTaken, err := isTeamNameTaken(cmd.Name, 0, sess); err != nil { + if isNameTaken, err := isTeamNameTaken(cmd.OrgId, cmd.Name, 0, sess); err != nil { return err } else if isNameTaken { return m.ErrTeamNameTaken @@ -50,7 +50,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error { func UpdateTeam(cmd *m.UpdateTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if isNameTaken, err := isTeamNameTaken(cmd.Name, cmd.Id, sess); err != nil { + if isNameTaken, err := isTeamNameTaken(cmd.OrgId, cmd.Name, cmd.Id, sess); err != nil { return err } else if isNameTaken { return m.ErrTeamNameTaken @@ -78,22 +78,23 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { }) } +// DeleteTeam will delete a team, its member and any permissions connected to the team func DeleteTeam(cmd *m.DeleteTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.Id); err != nil { + if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil { return err - } else if len(res) != 1 { + } else if !teamExists { return m.ErrTeamNotFound } deletes := []string{ - "DELETE FROM team_member WHERE team_id = ?", - "DELETE FROM team WHERE id = ?", - "DELETE FROM dashboard_acl WHERE team_id = ?", + "DELETE FROM team_member WHERE org_id=? and team_id = ?", + "DELETE FROM team WHERE org_id=? and id = ?", + "DELETE FROM dashboard_acl WHERE org_id=? and team_id = ?", } for _, sql := range deletes { - _, err := sess.Exec(sql, cmd.Id) + _, err := sess.Exec(sql, cmd.OrgId, cmd.Id) if err != nil { return err } @@ -102,9 +103,19 @@ func DeleteTeam(cmd *m.DeleteTeamCommand) error { }) } -func isTeamNameTaken(name string, existingId int64, sess *DBSession) (bool, error) { +func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) { + if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil { + return false, err + } else if len(res) != 1 { + return false, nil + } + + return true, nil +} + +func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession) (bool, error) { var team m.Team - exists, err := sess.Where("name=?", name).Get(&team) + exists, err := sess.Where("org_id=? and name=?", orgId, name).Get(&team) if err != nil { return false, nil @@ -128,6 +139,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error { sql.WriteString(`select team.id as id, + team.org_id, team.name as name, team.email as email, (select count(*) from team_member where team_member.team_id = team.id) as member_count @@ -176,7 +188,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error { func GetTeamById(query *m.GetTeamByIdQuery) error { var team m.Team - exists, err := x.Id(query.Id).Get(&team) + exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team) if err != nil { return err } @@ -189,12 +201,13 @@ func GetTeamById(query *m.GetTeamByIdQuery) error { return nil } +// GetTeamsByUser is used by the Guardian when checking a users' permissions 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) + sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId) err := sess.Find(&query.Result) if err != nil { @@ -204,17 +217,18 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { return nil } +// AddTeamMember adds a user to a team func AddTeamMember(cmd *m.AddTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { - if res, err := sess.Query("SELECT 1 from team_member WHERE team_id=? and user_id=?", cmd.TeamId, cmd.UserId); err != nil { + if res, err := sess.Query("SELECT 1 from team_member WHERE org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId); err != nil { return err } else if len(res) == 1 { return m.ErrTeamMemberAlreadyAdded } - if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.TeamId); err != nil { + if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { return err - } else if len(res) != 1 { + } else if !teamExists { return m.ErrTeamNotFound } @@ -231,23 +245,35 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { }) } +// RemoveTeamMember removes a member from a team 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 teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { + return err + } else if !teamExists { + return m.ErrTeamNotFound + } + + var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" + res, err := sess.Exec(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { return err } + rows, err := res.RowsAffected() + if rows == 0 { + return m.ErrTeamMemberNotFound + } return err }) } +// GetTeamMembers return a list of members for the specified team func GetTeamMembers(query *m.GetTeamMembersQuery) error { query.Result = make([]*m.TeamMemberDTO, 0) sess := x.Table("team_member") sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user"))) - sess.Where("team_member.team_id=?", query.TeamId) + sess.Where("team_member.org_id=? and team_member.team_id=?", query.OrgId, query.TeamId) sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login") sess.Asc("user.login", "user.email") diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index dbae4545266..f136411eeba 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -27,8 +27,9 @@ func TestTeamCommandsAndQueries(t *testing.T) { userIds = append(userIds, userCmd.Result.Id) } - group1 := m.CreateTeamCommand{Name: "group1 name", Email: "test1@test.com"} - group2 := m.CreateTeamCommand{Name: "group2 name", Email: "test2@test.com"} + var testOrgId int64 = 1 + group1 := m.CreateTeamCommand{OrgId: testOrgId, Name: "group1 name", Email: "test1@test.com"} + group2 := m.CreateTeamCommand{OrgId: testOrgId, Name: "group2 name", Email: "test2@test.com"} err := CreateTeam(&group1) So(err, ShouldBeNil) @@ -36,7 +37,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(err, ShouldBeNil) Convey("Should be able to create teams and add users", func() { - query := &m.SearchTeamsQuery{Name: "group1 name", Page: 1, Limit: 10} + query := &m.SearchTeamsQuery{OrgId: testOrgId, Name: "group1 name", Page: 1, Limit: 10} err = SearchTeams(query) So(err, ShouldBeNil) So(query.Page, ShouldEqual, 1) @@ -44,25 +45,27 @@ func TestTeamCommandsAndQueries(t *testing.T) { team1 := query.Result.Teams[0] So(team1.Name, ShouldEqual, "group1 name") So(team1.Email, ShouldEqual, "test1@test.com") + So(team1.OrgId, ShouldEqual, testOrgId) - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team1.Id, UserId: userIds[0]}) So(err, ShouldBeNil) - q1 := &m.GetTeamMembersQuery{TeamId: team1.Id} + q1 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team1.Id} err = GetTeamMembers(q1) So(err, ShouldBeNil) So(q1.Result[0].TeamId, ShouldEqual, team1.Id) So(q1.Result[0].Login, ShouldEqual, "loginuser0") + So(q1.Result[0].OrgId, ShouldEqual, testOrgId) }) Convey("Should be able to search for teams", func() { - query := &m.SearchTeamsQuery{Query: "group", Page: 1} + query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1} err = SearchTeams(query) So(err, ShouldBeNil) So(len(query.Result.Teams), ShouldEqual, 2) So(query.Result.TotalCount, ShouldEqual, 2) - query2 := &m.SearchTeamsQuery{Query: ""} + query2 := &m.SearchTeamsQuery{OrgId: testOrgId, Query: ""} err = SearchTeams(query2) So(err, ShouldBeNil) So(len(query2.Result.Teams), ShouldEqual, 2) @@ -70,9 +73,9 @@ func TestTeamCommandsAndQueries(t *testing.T) { Convey("Should be able to return all teams a user is member of", func() { groupId := group2.Result.Id - err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[0]}) + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[0]}) - query := &m.GetTeamsByUserQuery{UserId: userIds[0]} + query := &m.GetTeamsByUserQuery{OrgId: testOrgId, UserId: userIds[0]} err = GetTeamsByUser(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) @@ -81,31 +84,34 @@ func TestTeamCommandsAndQueries(t *testing.T) { }) Convey("Should be able to remove users from a group", func() { - err = RemoveTeamMember(&m.RemoveTeamMemberCommand{TeamId: group1.Result.Id, UserId: userIds[0]}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0]}) So(err, ShouldBeNil) - q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id} - err = GetTeamMembers(q1) + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0]}) So(err, ShouldBeNil) - So(len(q1.Result), ShouldEqual, 0) + + q2 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: group1.Result.Id} + err = GetTeamMembers(q2) + So(err, ShouldBeNil) + So(len(q2.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]}) + err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]}) So(err, ShouldBeNil) - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[2]}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[2]}) So(err, ShouldBeNil) - err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, TeamId: groupId}) + err = testHelperUpdateDashboardAcl(1, m.DashboardAcl{DashboardId: 1, OrgId: testOrgId, Permission: m.PERMISSION_EDIT, TeamId: groupId}) - err = DeleteTeam(&m.DeleteTeamCommand{Id: groupId}) + err = DeleteTeam(&m.DeleteTeamCommand{OrgId: testOrgId, Id: groupId}) So(err, ShouldBeNil) - query := &m.GetTeamByIdQuery{Id: groupId} + query := &m.GetTeamByIdQuery{OrgId: testOrgId, Id: groupId} err = GetTeamById(query) So(err, ShouldEqual, m.ErrTeamNotFound) - permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1} + permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: testOrgId} err = GetDashboardAclInfoList(permQuery) So(err, ShouldBeNil) diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index a65b7226eb6..2830733c96a 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -99,7 +99,7 @@ func TestUserDataAccess(t *testing.T) { err = AddOrgUser(&m.AddOrgUserCommand{LoginOrEmail: users[0].Login, Role: m.ROLE_VIEWER, OrgId: users[0].OrgId}) So(err, ShouldBeNil) - err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[0].Id, Permission: m.PERMISSION_EDIT}) + testHelperUpdateDashboardAcl(1, m.DashboardAcl{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[0].Id, Permission: m.PERMISSION_EDIT}) So(err, ShouldBeNil) err = SavePreferences(&m.SavePreferencesCommand{UserId: users[0].Id, OrgId: users[0].OrgId, HomeDashboardId: 1, Theme: "dark"}) diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 251527ab4e5..c82cff390c3 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -98,11 +98,13 @@ func init() { "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"}, "AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"}, "AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"}, + "AWS/States": {"ExecutionTime", "ExecutionThrottled", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesScheduled", "ActivitiesSucceeded", "ActivitiesTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut"}, "AWS/StorageGateway": {"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "CloudBytesDownloaded", "CloudDownloadLatency", "CloudBytesUploaded", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "QueuedWrites", "ReadBytes", "ReadTime", "TotalCacheSize", "WriteBytes", "WriteTime", "TimeSinceLastRecoveryPoint", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "ReadBytes", "ReadTime", "WriteBytes", "WriteTime", "QueuedWrites"}, "AWS/SWF": {"DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "StartedDecisionTasksTimedOutOnClose", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut", "ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat"}, "AWS/VPN": {"TunnelState", "TunnelDataIn", "TunnelDataOut"}, + "Rekognition": {"SuccessfulRequestCount", "ThrottledCount", "ResponseTime", "DetectedFaceCount", "DetectedLabelCount", "ServerErrorCount", "UserErrorCount"}, "WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests"}, "AWS/WorkSpaces": {"Available", "Unhealthy", "ConnectionAttempt", "ConnectionSuccess", "ConnectionFailure", "SessionLaunchTime", "InSessionLatency", "SessionDisconnect"}, "KMS": {"SecondsUntilKeyMaterialExpiration"}, @@ -145,9 +147,11 @@ func init() { "AWS/SES": {}, "AWS/SNS": {"Application", "Platform", "TopicName"}, "AWS/SQS": {"QueueName"}, + "AWS/States": {"StateMachineArn", "ActivityArn", "LambdaFunctionArn"}, "AWS/StorageGateway": {"GatewayId", "GatewayName", "VolumeId"}, "AWS/SWF": {"Domain", "WorkflowTypeName", "WorkflowTypeVersion", "ActivityTypeName", "ActivityTypeVersion"}, "AWS/VPN": {"VpnId", "TunnelIpAddress"}, + "Rekognition": {}, "WAF": {"Rule", "WebACL"}, "AWS/WorkSpaces": {"DirectoryId", "WorkspaceId"}, "KMS": {"KeyId"}, diff --git a/pkg/tsdb/influxdb/query.go b/pkg/tsdb/influxdb/query.go index 499f446e9f0..0a16a507877 100644 --- a/pkg/tsdb/influxdb/query.go +++ b/pkg/tsdb/influxdb/query.go @@ -70,7 +70,7 @@ func (query *Query) renderTags() []string { } else if tag.Operator == "<" || tag.Operator == ">" { textValue = tag.Value } else { - textValue = fmt.Sprintf("'%s'", tag.Value) + textValue = fmt.Sprintf("'%s'", strings.Replace(tag.Value, `\`, `\\`, -1)) } res = append(res, fmt.Sprintf(`%s"%s" %s %s`, str, tag.Key, tag.Operator, textValue)) diff --git a/pkg/tsdb/influxdb/query_test.go b/pkg/tsdb/influxdb/query_test.go index 4a620539a26..f1270560269 100644 --- a/pkg/tsdb/influxdb/query_test.go +++ b/pkg/tsdb/influxdb/query_test.go @@ -170,6 +170,12 @@ func TestInfluxdbQueryBuilder(t *testing.T) { So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 'value'`) }) + Convey("can escape backslashes when rendering string tags", func() { + query := &Query{Tags: []*Tag{{Operator: "=", Value: `C:\test\`, Key: "key"}}} + + So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 'C:\\test\\'`) + }) + Convey("can render regular measurement", func() { query := &Query{Measurement: `apa`, Policy: "policy"} diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index a8c96d8119c..ca96b6c7a20 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -220,14 +220,14 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co case time.Time: timestamp = float64(columnValue.UnixNano() / 1e6) default: - return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp") + return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) } if metricIndex >= 0 { if columnValue, ok := values[metricIndex].(string); ok == true { metric = columnValue } else { - return fmt.Errorf("Column metric must be of type char,varchar or text") + return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) } } diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go index 067f7c756ba..d87b6f70fe6 100644 --- a/pkg/util/shortid_generator.go +++ b/pkg/util/shortid_generator.go @@ -1,14 +1,29 @@ package util import ( + "regexp" + "github.com/teris-io/shortid" ) +var allowedChars = shortid.DefaultABC + +var validUidPattern = regexp.MustCompile(`^[a-zA-Z0-9\-\_]*$`).MatchString + func init() { - gen, _ := shortid.New(1, shortid.DefaultABC, 1) + gen, _ := shortid.New(1, allowedChars, 1) shortid.SetDefault(gen) } +// IsValidShortUid checks if short unique identifier contains valid characters +func IsValidShortUid(uid string) bool { + if !validUidPattern(uid) { + return false + } + + return true +} + // GenerateShortUid generates a short unique identifier. func GenerateShortUid() string { return shortid.MustGenerate() diff --git a/pkg/util/shortid_generator_test.go b/pkg/util/shortid_generator_test.go new file mode 100644 index 00000000000..359e054a0ca --- /dev/null +++ b/pkg/util/shortid_generator_test.go @@ -0,0 +1,11 @@ +package util + +import "testing" + +func TestAllowedCharMatchesUidPattern(t *testing.T) { + for _, c := range allowedChars { + if !IsValidShortUid(string(c)) { + t.Fatalf("charset for creating new shortids contains chars not present in uid pattern") + } + } +} diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx index 6fb6e3b7d8f..9ecb9a177d7 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.tsx @@ -147,8 +147,7 @@ export class AlertRuleItem extends React.Component {
- {rule.canEdit && {this.renderText(rule.name)}} - {!rule.canEdit && {this.renderText(rule.name)}} + {this.renderText(rule.name)}
{this.renderText(rule.stateText)} @@ -163,24 +162,12 @@ export class AlertRuleItem extends React.Component { className="btn btn-small btn-inverse alert-list__btn width-2" title="Pausing an alert rule prevents it from executing" onClick={this.toggleState} - disabled={!rule.canEdit} > - {rule.canEdit && ( - - - - )} - {!rule.canEdit && ( - - )} + + +
); diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap index 0914f050a0f..f408f6409be 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap +++ b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap @@ -82,7 +82,6 @@ exports[`AlertRuleList should render 1 rule 1`] = ` >
Min width -
diff --git a/public/app/partials/reset_password.html b/public/app/partials/reset_password.html index 340d8f8f329..92ed91d5f4d 100644 --- a/public/app/partials/reset_password.html +++ b/public/app/partials/reset_password.html @@ -18,7 +18,7 @@
-
+
An email with a reset link as been sent to the email address.
You should receive it shortly.
@@ -27,5 +27,23 @@
+
diff --git a/public/app/plugins/datasource/cloudwatch/README.md b/public/app/plugins/datasource/cloudwatch/README.md index a1b7bf5cc50..5e420a4fada 100644 --- a/public/app/plugins/datasource/cloudwatch/README.md +++ b/public/app/plugins/datasource/cloudwatch/README.md @@ -1,7 +1,7 @@ -# CloudWatch Datasource - Native Plugin +# CloudWatch Data Source - Native Plugin Grafana ships with **built in** support for CloudWatch. You just have to add it as a data source and you will be ready to build dashboards for you CloudWatch metrics. Read more about it here: -[http://docs.grafana.org/datasources/cloudwatch/](http://docs.grafana.org/datasources/cloudwatch/) \ No newline at end of file +[http://docs.grafana.org/datasources/cloudwatch/](http://docs.grafana.org/datasources/cloudwatch/) diff --git a/public/app/plugins/datasource/cloudwatch/plugin.json b/public/app/plugins/datasource/cloudwatch/plugin.json index 3af7d8ccb6e..20cfcf661c5 100644 --- a/public/app/plugins/datasource/cloudwatch/plugin.json +++ b/public/app/plugins/datasource/cloudwatch/plugin.json @@ -8,6 +8,7 @@ "annotations": true, "info": { + "description": "Cloudwatch Data Source for Grafana", "author": { "name": "Grafana Project", "url": "https://grafana.com" @@ -15,6 +16,7 @@ "logos": { "small": "img/amazon-web-services.png", "large": "img/amazon-web-services.png" - } + }, + "version": "5.0.0" } } diff --git a/public/app/plugins/datasource/elasticsearch/README.md b/public/app/plugins/datasource/elasticsearch/README.md index 22445b022fe..fd1f2f74f7a 100644 --- a/public/app/plugins/datasource/elasticsearch/README.md +++ b/public/app/plugins/datasource/elasticsearch/README.md @@ -1,4 +1,4 @@ -# Elasticsearch Datasource - Native Plugin +# Elasticsearch Data Source - Native Plugin Grafana ships with **advanced support** for Elasticsearch. You can do many types of simple or complex elasticsearch queries to visualize logs or metrics stored in Elasticsearch. You can also annotate your graphs with log events stored in Elasticsearch. diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 1c9f46867f9..7476a36405b 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -166,7 +166,7 @@ export class ElasticDatasource { for (var i = 0; i < hits.length; i++) { var source = hits[i]._source; - var time = source[timeField]; + var time = getFieldFromSource(source, timeField); if (typeof hits[i].fields !== 'undefined') { var fields = hits[i].fields; if (_.isString(fields[timeField]) || _.isNumber(fields[timeField])) { diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json index f86c2f8f89f..59d26b785ac 100644 --- a/public/app/plugins/datasource/elasticsearch/plugin.json +++ b/public/app/plugins/datasource/elasticsearch/plugin.json @@ -17,7 +17,7 @@ "links": [ {"name": "elastic.co", "url": "https://www.elastic.co/products/elasticsearch"} ], - "version": "3.0.0" + "version": "5.0.0" }, "annotations": true, diff --git a/public/app/plugins/datasource/graphite/README.md b/public/app/plugins/datasource/graphite/README.md index c27c5789bca..68e3a36da63 100644 --- a/public/app/plugins/datasource/graphite/README.md +++ b/public/app/plugins/datasource/graphite/README.md @@ -6,4 +6,8 @@ Grafana has an advanced Graphite query editor that lets you quickly navigate the Read more about it here: -[http://docs.grafana.org/datasources/graphite/](http://docs.grafana.org/datasources/graphite/) \ No newline at end of file +[http://docs.grafana.org/datasources/graphite/](http://docs.grafana.org/datasources/graphite/) + +Graphite 1.1 Release: + +[https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/](https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/) diff --git a/public/app/plugins/datasource/graphite/partials/query.editor.html b/public/app/plugins/datasource/graphite/partials/query.editor.html index 6414a39c94f..51c25100c1e 100755 --- a/public/app/plugins/datasource/graphite/partials/query.editor.html +++ b/public/app/plugins/datasource/graphite/partials/query.editor.html @@ -13,9 +13,9 @@
diff --git a/public/app/plugins/panel/heatmap/README.md b/public/app/plugins/panel/heatmap/README.md index e69de29bb2d..1b95a02c4cb 100644 --- a/public/app/plugins/panel/heatmap/README.md +++ b/public/app/plugins/panel/heatmap/README.md @@ -0,0 +1,7 @@ +# Heatmap Panel - Native Plugin + +The Heatmap panel allows you to view histograms over time and is **included** with Grafana. + +Read more about it here: + +[http://docs.grafana.org/features/panels/heatmap/](http://docs.grafana.org/features/panels/heatmap/) diff --git a/public/app/plugins/panel/heatmap/plugin.json b/public/app/plugins/panel/heatmap/plugin.json index bee509467a7..723d8e886a0 100644 --- a/public/app/plugins/panel/heatmap/plugin.json +++ b/public/app/plugins/panel/heatmap/plugin.json @@ -4,6 +4,7 @@ "id": "heatmap", "info": { + "description": "Heatmap Panel for Grafana", "author": { "name": "Grafana Project", "url": "https://grafana.com" @@ -11,6 +12,11 @@ "logos": { "small": "img/icn-heatmap-panel.svg", "large": "img/icn-heatmap-panel.svg" - } + }, + "links": [ + {"name": "Brendan Gregg - Heatmaps", "url": "http://www.brendangregg.com/heatmaps.html"}, + {"name": "Brendan Gregg - Latency Heatmaps", "url": " http://www.brendangregg.com/HeatMaps/latency.html"} + ], + "version": "5.0.0" } } diff --git a/public/app/plugins/panel/pluginlist/README.md b/public/app/plugins/panel/pluginlist/README.md index 463769dad1f..3aacada938b 100644 --- a/public/app/plugins/panel/pluginlist/README.md +++ b/public/app/plugins/panel/pluginlist/README.md @@ -1,2 +1,3 @@ # Plugin List Panel - Native Plugin +The Plugin List plans shows the installed plugins for your Grafana instance and is **included** with Grafana. It is used on the default Home dashboard. diff --git a/public/app/plugins/panel/pluginlist/plugin.json b/public/app/plugins/panel/pluginlist/plugin.json index 0cdc506167c..b955177f1bc 100644 --- a/public/app/plugins/panel/pluginlist/plugin.json +++ b/public/app/plugins/panel/pluginlist/plugin.json @@ -4,6 +4,7 @@ "id": "pluginlist", "info": { + "description": "Plugin List for Grafana", "author": { "name": "Grafana Project", "url": "https://grafana.com" @@ -11,6 +12,7 @@ "logos": { "small": "img/icn-dashlist-panel.svg", "large": "img/icn-dashlist-panel.svg" - } + }, + "version": "5.0.0" } } diff --git a/public/app/plugins/panel/singlestat/plugin.json b/public/app/plugins/panel/singlestat/plugin.json index 8bf45d3b690..5e36ab5cf4e 100644 --- a/public/app/plugins/panel/singlestat/plugin.json +++ b/public/app/plugins/panel/singlestat/plugin.json @@ -4,6 +4,7 @@ "id": "singlestat", "info": { + "description": "Singlestat Panel for Grafana", "author": { "name": "Grafana Project", "url": "https://grafana.com" @@ -11,7 +12,8 @@ "logos": { "small": "img/icn-singlestat-panel.svg", "large": "img/icn-singlestat-panel.svg" - } + }, + "version": "5.0.0" } } diff --git a/public/app/plugins/panel/table/README.md b/public/app/plugins/panel/table/README.md index 48c4fac641b..98f2c13f75c 100644 --- a/public/app/plugins/panel/table/README.md +++ b/public/app/plugins/panel/table/README.md @@ -6,4 +6,4 @@ The table panel is very flexible, supporting both multiple modes for time series Check out the [Table Panel Showcase in the Grafana Playground](http://play.grafana.org/dashboard/db/table-panel-showcase) or read more about it here: -[http://docs.grafana.org/reference/table_panel/](http://docs.grafana.org/reference/table_panel/) \ No newline at end of file +[http://docs.grafana.org/reference/table_panel/](http://docs.grafana.org/reference/table_panel/) diff --git a/public/app/plugins/panel/table/plugin.json b/public/app/plugins/panel/table/plugin.json index c1d2c43b128..3b6cbaae876 100644 --- a/public/app/plugins/panel/table/plugin.json +++ b/public/app/plugins/panel/table/plugin.json @@ -4,6 +4,7 @@ "id": "table", "info": { + "description": "Table Panel for Grafana", "author": { "name": "Grafana Project", "url": "https://grafana.com" @@ -11,7 +12,8 @@ "logos": { "small": "img/icn-table-panel.svg", "large": "img/icn-table-panel.svg" - } + }, + "version": "5.0.0" } } diff --git a/public/app/plugins/panel/text/plugin.json b/public/app/plugins/panel/text/plugin.json index 9bc604a87bb..6152bd322b5 100644 --- a/public/app/plugins/panel/text/plugin.json +++ b/public/app/plugins/panel/text/plugin.json @@ -11,7 +11,8 @@ "logos": { "small": "img/icn-text-panel.svg", "large": "img/icn-text-panel.svg" - } + }, + "version": "5.0.0" } } diff --git a/public/app/stores/AlertListStore/AlertRule.ts b/public/app/stores/AlertListStore/AlertRule.ts index 00881650949..9c039be6ec2 100644 --- a/public/app/stores/AlertListStore/AlertRule.ts +++ b/public/app/stores/AlertListStore/AlertRule.ts @@ -14,7 +14,6 @@ export const AlertRule = types stateAge: types.string, info: types.optional(types.string, ''), url: types.string, - canEdit: types.boolean, }) .views(self => ({ get isPaused() { diff --git a/public/app/stores/PermissionsStore/PermissionsStore.ts b/public/app/stores/PermissionsStore/PermissionsStore.ts index 02413f6b0a1..a7c90d13da0 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.ts @@ -108,6 +108,8 @@ export const PermissionsStore = types self.isFolder = isFolder; self.isInRoot = isInRoot; self.dashboardId = dashboardId; + self.items.clear(); + const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`); const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot); self.items = items; diff --git a/public/app/stores/PermissionsStore/PermissionsStoreItem.ts b/public/app/stores/PermissionsStore/PermissionsStoreItem.ts index 74769891256..92dca0220ca 100644 --- a/public/app/stores/PermissionsStore/PermissionsStoreItem.ts +++ b/public/app/stores/PermissionsStore/PermissionsStoreItem.ts @@ -1,9 +1,8 @@ -import { types } from 'mobx-state-tree'; +import { types } from 'mobx-state-tree'; export const PermissionsStoreItem = types .model('PermissionsStoreItem', { dashboardId: types.optional(types.number, -1), - id: types.maybe(types.number), permission: types.number, permissionName: types.maybe(types.string), role: types.maybe(types.string), diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index e3f442b8908..eb598f27d4c 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -72,7 +72,7 @@ $textShadow: none; // gradients $brand-gradient: linear-gradient(to right, rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%); -$page-gradient: linear-gradient(-60deg, transparent 70%, $gray-7 98%); +$page-gradient: linear-gradient(-60deg, $gray-7, #f5f6f9 70%, $gray-7 98%); // Links // ------------------------- diff --git a/public/sass/base/_fonts.scss b/public/sass/base/_fonts.scss index 4e680872b5a..aab86329612 100644 --- a/public/sass/base/_fonts.scss +++ b/public/sass/base/_fonts.scss @@ -1,5 +1,5 @@ -@import "base/font_awesome"; -@import "base/grafana_icons"; +@import "font_awesome"; +@import "grafana_icons"; /* cyrillic-ext */ @font-face { diff --git a/public/sass/components/_dashboard_grid.scss b/public/sass/components/_dashboard_grid.scss index ec197cbe3a4..0a27df75164 100644 --- a/public/sass/components/_dashboard_grid.scss +++ b/public/sass/components/_dashboard_grid.scss @@ -41,8 +41,15 @@ .theme-dark { .react-grid-item > .react-resizable-handle::after { - border-right: 2px solid rgba(255, 255, 255, 0.4); - border-bottom: 2px solid rgba(255, 255, 255, 0.4); + border-right: 2px solid $gray-1; + border-bottom: 2px solid $gray-1; + } +} + +.theme-light { + .react-grid-item > .react-resizable-handle::after { + border-right: 2px solid $gray-3; + border-bottom: 2px solid $gray-3; } } diff --git a/public/sass/components/_modals.scss b/public/sass/components/_modals.scss index df435462549..260c1c9fc4d 100644 --- a/public/sass/components/_modals.scss +++ b/public/sass/components/_modals.scss @@ -67,6 +67,11 @@ .modal-content { padding: $spacer*2; + + &--has-scroll { + max-height: calc(100vh - 400px); + position: relative; + } } // Remove bottom margin if need be diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index d03c8e0efb3..716778096d6 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -16,6 +16,10 @@ padding-left: 0px; } + .graph-legend-table { + width: auto; + } + .graph-legend-table .graph-legend-series { display: table-row; } @@ -45,7 +49,7 @@ .graph-legend { flex: 0 1 auto; max-height: 30%; - margin: 0 $spacer; + margin: 0; text-align: center; padding-top: 6px; position: relative; @@ -70,19 +74,19 @@ font-size: 85%; text-align: left; &.current::before { - content: "Current: "; + content: 'Current: '; } &.max::before { - content: "Max: "; + content: 'Max: '; } &.min::before { - content: "Min: "; + content: 'Min: '; } &.total::before { - content: "Total: "; + content: 'Total: '; } &.avg::before { - content: "Avg: "; + content: 'Avg: '; } } @@ -106,6 +110,15 @@ padding-left: 6px; } +// fix for phantomjs +.body--phantomjs { + .graph-panel--legend-right { + .graph-legend-table { + display: table; + } + } +} + .graph-legend-table { tbody { display: block; @@ -114,6 +127,7 @@ height: 100%; padding-bottom: 1px; padding-right: 5px; + padding-left: 5px; } .graph-legend-series { @@ -124,7 +138,7 @@ float: none; .graph-legend-alias::after { - content: "(right-y)"; + content: '(right-y)'; padding: 0 5px; color: $text-color-weak; } @@ -175,7 +189,7 @@ &.total, &.avg { &::before { - content: ""; + content: ''; } } } diff --git a/tests/api/clearState.test.ts b/tests/api/clearState.test.ts new file mode 100644 index 00000000000..8c5e5c1422b --- /dev/null +++ b/tests/api/clearState.test.ts @@ -0,0 +1,7 @@ +import * as setup from './setup'; + +describe.skip('clear state', () => { + it('will clear state', () => { + return setup.clearState(); + }); +}); diff --git a/tests/api/client.ts b/tests/api/client.ts new file mode 100644 index 00000000000..2e2f9ed67fd --- /dev/null +++ b/tests/api/client.ts @@ -0,0 +1,30 @@ +const axios = require('axios'); + +export function getClient(options) { + return axios.create({ + baseURL: 'http://localhost:3000', + timeout: 1000, + auth: { + username: options.username, + password: options.password, + }, + }); +} + +export function getAdminClient() { + return getClient({ + username: 'admin', + password: 'admin', + }); +} + +let client = getAdminClient(); + +client.callAs = function(user) { + return getClient({ + username: user.login, + password: 'password', + }); +}; + +export default client; diff --git a/tests/api/dashboard.test.ts b/tests/api/dashboard.test.ts new file mode 100644 index 00000000000..55beaffecb0 --- /dev/null +++ b/tests/api/dashboard.test.ts @@ -0,0 +1,45 @@ +import client from './client'; +import * as setup from './setup'; + +describe('/api/dashboards', () => { + let state: any = {}; + + beforeAll(async () => { + state = await setup.ensureState({ + orgName: 'api-test-org', + users: [ + { user: setup.admin, role: 'Admin' }, + { user: setup.editor, role: 'Editor' }, + { user: setup.viewer, role: 'Viewer' }, + ], + admin: setup.admin, + dashboards: [ + { + title: 'aaa', + uid: 'aaa', + }, + { + title: 'bbb', + uid: 'bbb', + }, + ], + }); + }); + + describe('With admin user', () => { + it('can delete dashboard', async () => { + let rsp = await client.callAs(setup.admin).delete(`/api/dashboards/uid/aaa`); + expect(rsp.data.title).toBe('aaa'); + }); + }); + + describe('With viewer user', () => { + it('Cannot delete dashboard', async () => { + let rsp = await setup.expectError(() => { + return client.callAs(setup.viewer).delete(`/api/dashboards/uid/bbb`); + }); + + expect(rsp.response.status).toBe(403); + }); + }); +}); diff --git a/tests/api/jest.js b/tests/api/jest.js new file mode 100644 index 00000000000..b32573115b7 --- /dev/null +++ b/tests/api/jest.js @@ -0,0 +1,19 @@ +module.exports = { + verbose: true, + "globals": { + "ts-jest": { + "tsConfigFile": "tsconfig.json" + } + }, + "transform": { + "^.+\\.tsx?$": "/../../node_modules/ts-jest/preprocessor.js" + }, + "moduleDirectories": ["node_modules"], + "testRegex": "(\\.|/)(test)\\.ts$", + "testEnvironment": "node", + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], +}; diff --git a/tests/api/search.test.ts b/tests/api/search.test.ts new file mode 100644 index 00000000000..91d1ebf0d35 --- /dev/null +++ b/tests/api/search.test.ts @@ -0,0 +1,27 @@ +import client from './client'; +import * as setup from './setup'; + +describe('GET /api/search', () => { + const state = {}; + + beforeAll(async () => { + state = await setup.ensureState({ + orgName: 'api-test-org', + users: [{ user: setup.admin, role: 'Admin' }], + admin: setup.admin, + dashboards: [ + { + title: 'Dashboard in root no permissions', + uid: 'AAA', + }, + ], + }); + }); + + describe('With admin user', () => { + it('should return all dashboards', async () => { + let rsp = await client.callAs(state.admin).get('/api/search'); + expect(rsp.data).toHaveLength(1); + }); + }); +}); diff --git a/tests/api/setup.ts b/tests/api/setup.ts new file mode 100644 index 00000000000..0566729999c --- /dev/null +++ b/tests/api/setup.ts @@ -0,0 +1,107 @@ +import client from './client'; +import _ from 'lodash;'; + +export const editor = { + email: 'api-test-editor@grafana.com', + login: 'api-test-editor', + password: 'password', + name: 'Api Test Editor', +}; + +export const admin = { + email: 'api-test-admin@grafana.com', + login: 'api-test-admin', + password: 'password', + name: 'Api Test Super', +}; + +export const viewer = { + email: 'api-test-viewer@grafana.com', + login: 'api-test-viewer', + password: 'password', + name: 'Api Test Viewer', +}; + +export async function expectError(callback) { + try { + let rsp = await callback(); + return rsp; + } catch (err) { + return err; + } + + return rsp; +} + +// deletes org if it's already there +export async function getOrg(orgName) { + try { + const rsp = await client.get(`/api/orgs/name/${orgName}`); + await client.delete(`/api/orgs/${rsp.data.id}`); + } catch {} + + const rsp = await client.post(`/api/orgs`, { name: orgName }); + return { name: orgName, id: rsp.data.orgId }; +} + +export async function getUser(user) { + const search = await client.get('/api/users/search', { + params: { query: user.login }, + }); + + if (search.data.totalCount === 1) { + user.id = search.data.users[0].id; + return user; + } + + const rsp = await client.post('/api/admin/users', user); + user.id = rsp.data.id; + + return user; +} + +export async function addUserToOrg(org, user, role) { + const rsp = await client.post(`/api/orgs/${org.id}/users`, { + loginOrEmail: user.login, + role: role, + }); + + return rsp.data; +} + +export async function clearState() { + const admin = await getUser(adminUser); + const rsp = await client.delete(`/api/admin/users/${admin.id}`); + return rsp.data; +} + +export async function setUsingOrg(user, org) { + await client.callAs(user).post(`/api/user/using/${org.id}`); +} + +export async function createDashboard(user, dashboard) { + const rsp = await client.callAs(user).post(`/api/dashboards/db`, { + dashboard: dashboard, + overwrite: true, + }); + dashboard.id = rsp.data.id; + dashboard.url = rsp.data.url; + + return dashboard; +} + +export async function ensureState(state) { + const org = await getOrg(state.orgName); + + for (let orgUser of state.users) { + const user = await getUser(orgUser.user); + await addUserToOrg(org, user, orgUser.role); + await setUsingOrg(user, org); + } + + for (let dashboard of state.dashboards) { + await createDashboard(state.admin, dashboard); + } + + return state; +} diff --git a/tests/api/tsconfig.json b/tests/api/tsconfig.json new file mode 100644 index 00000000000..3dd8c94d7d0 --- /dev/null +++ b/tests/api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es6", + "lib": ["es6"], + "module": "commonjs", + "declaration": false, + "allowSyntheticDefaultImports": true, + "inlineSourceMap": false, + "sourceMap": true, + "noEmitOnError": false, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "noImplicitReturns": true, + "noImplicitThis": false, + "noImplicitUseStrict":false, + "noImplicitAny": false, + "noUnusedLocals": true + }, + "include": [ + "*.ts", + "**/*.ts" + ] +} diff --git a/tests/api/user.test.ts b/tests/api/user.test.ts new file mode 100644 index 00000000000..ef1c927c69e --- /dev/null +++ b/tests/api/user.test.ts @@ -0,0 +1,22 @@ +import client from './client'; +import * as setup from './setup'; + +describe('GET /api/user', () => { + it('should return current authed user', async () => { + let rsp = await client.get('/api/user'); + expect(rsp.data.login).toBe('admin'); + }); +}); + +describe('PUT /api/user', () => { + it('should update current authed user', async () => { + const user = await setup.getUser(setup.editor); + user.name = 'Updated via test'; + + const rsp = await client.callAs(user).put('/api/user', user); + expect(rsp.data.message).toBe('User updated'); + + const updated = await client.callAs(user).get('/api/user'); + expect(updated.data.name).toBe('Updated via test'); + }); +}); diff --git a/tools/phantomjs/render.js b/tools/phantomjs/render.js index 6ae9b5773b0..77527585589 100644 --- a/tools/phantomjs/render.js +++ b/tools/phantomjs/render.js @@ -1,42 +1,42 @@ (function() { 'use strict'; - + var page = require('webpage').create(); var args = require('system').args; var params = {}; var regexp = /^([^=]+)=([^$]+)/; - + args.forEach(function(arg) { var parts = arg.match(regexp); if (!parts) { return; } params[parts[1]] = parts[2]; }); - + var usage = "url= png= width= height= renderKey="; - + if (!params.url || !params.png || !params.renderKey || !params.domain) { console.log(usage); phantom.exit(); } - + phantom.addCookie({ 'name': 'renderKey', 'value': params.renderKey, 'domain': params.domain, }); - + page.viewportSize = { width: params.width || '800', height: params.height || '400' }; - + var timeoutMs = (parseInt(params.timeout) || 10) * 1000; var waitBetweenReadyCheckMs = 50; var totalWaitMs = 0; - + page.open(params.url, function (status) { console.log('Loading a web page: ' + params.url + ' status: ' + status, timeoutMs); - + page.onError = function(msg, trace) { var msgStack = ['ERROR: ' + msg]; if (trace && trace.length) { @@ -47,32 +47,32 @@ } console.error(msgStack.join('\n')); }; - + function checkIsReady() { var panelsRendered = page.evaluate(function() { if (!window.angular) { return false; } var body = window.angular.element(document.body); if (!body.injector) { return false; } if (!body.injector()) { return false; } - + var rootScope = body.injector().get('$rootScope'); if (!rootScope) {return false;} var panels = angular.element('div.panel:visible').length; return rootScope.panelsRendered >= panels; }); - + if (panelsRendered || totalWaitMs > timeoutMs) { var bb = page.evaluate(function () { return document.getElementsByClassName("main-view")[0].getBoundingClientRect(); }); - + page.clipRect = { top: bb.top, left: bb.left, width: bb.width, height: bb.height }; - + page.render(params.png); phantom.exit(); } else { @@ -80,7 +80,7 @@ setTimeout(checkIsReady, waitBetweenReadyCheckMs); } } - + setTimeout(checkIsReady, waitBetweenReadyCheckMs); }); - })(); \ No newline at end of file + })(); diff --git a/yarn.lock b/yarn.lock index e95968736ba..a78fcf0c3d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -699,6 +699,13 @@ aws4@^1.2.1, aws4@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" +axios@^0.17.1: + version "0.17.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.17.1.tgz#2d8e3e5d0bdbd7327f91bc814f5c57660f81824d" + dependencies: + follow-redirects "^1.2.5" + is-buffer "^1.1.5" + babel-code-frame@^6.11.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -3748,6 +3755,12 @@ flush-write-stream@^1.0.0: inherits "^2.0.1" readable-stream "^2.0.4" +follow-redirects@^1.2.5: + version "1.4.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.4.1.tgz#d8120f4518190f55aac65bb6fc7b85fcd666d6aa" + dependencies: + debug "^3.1.0" + for-in@^0.1.3: version "0.1.8" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" @@ -6512,6 +6525,10 @@ moment@^2.18.1: version "2.19.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.2.tgz#8a7f774c95a64550b4c7ebd496683908f9419dbe" +mousetrap-global-bind@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mousetrap-global-bind/-/mousetrap-global-bind-1.1.0.tgz#cd7de9222bd0646fa2e010d54c84a74c26a88edd" + mousetrap@^1.6.0: version "1.6.1" resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.1.tgz#2a085f5c751294c75e7e81f6ec2545b29cbf42d9"