diff --git a/.circleci/config.yml b/.circleci/config.yml index 465be85d508..6e3cfc1e840 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -88,7 +88,7 @@ jobs: test-frontend: docker: - - image: circleci/node:6.11.4 + - image: circleci/node:8 steps: - checkout - run: diff --git a/.gitignore b/.gitignore index 45dcb52e8d8..25325b37890 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ public/css/*.min.css *.tmp .DS_Store .vscode/ +.vs/ /data/* /bin/* diff --git a/CHANGELOG.md b/CHANGELOG.md index f1202688b96..7df1533e86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,36 @@ # 5.3.0 (unreleased) +* **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano) * **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon) +### Minor + +* **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley) +* **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248) + # 5.2.0 (unreleased) +### Minor + +* **Plugins**: Handle errors correctly when loading datasource plugin [#12383](https://github.com/grafana/grafana/pull/12383) thx [@rozetko](https://github.com/rozetko) +* **Render**: Enhance error message if phantomjs executable is not found [#11868](https://github.com/grafana/grafana/issues/11868) +* **Dashboard**: Set correct text in drop down when variable is present in url [#11968](https://github.com/grafana/grafana/issues/11968) + +# 5.2.0-beta3 (2018-06-21) + +### Minor + +* **Build**: All rpm packages should be signed [#12359](https://github.com/grafana/grafana/issues/12359) + +# 5.2.0-beta2 (2018-06-20) + ### New Features * **Dashboard**: Import dashboard to folder [#10796](https://github.com/grafana/grafana/issues/10796) ### Minor +* **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343) * **Dashboard**: Fix so panel titles doesn't wrap [#11074](https://github.com/grafana/grafana/issues/11074) * **Dashboard**: Prevent double-click when saving dashboard [#11963](https://github.com/grafana/grafana/issues/11963) * **Dashboard**: AutoFocus the add-panel search filter [#12189](https://github.com/grafana/grafana/pull/12189) thx [@ryantxu](https://github.com/ryantxu) @@ -21,6 +42,15 @@ * **Auth Proxy**: Whitelist proxy IP address instead of client IP address [#10707](https://github.com/grafana/grafana/issues/10707) * **User Management**: Make sure that a user always has a current org assigned [#11076](https://github.com/grafana/grafana/issues/11076) * **Snapshots**: Fix: annotations not properly extracted leading to incorrect rendering of annotations [#12278](https://github.com/grafana/grafana/issues/12278) +* **LDAP**: Allow use of DN in group_search_filter_user_attribute and member_of [#3132](https://github.com/grafana/grafana/issues/3132), thx [@mmolnar](https://github.com/mmolnar) +* **Graph**: Fix legend decimals precision calculation [#11792](https://github.com/grafana/grafana/issues/11792) +* **Dashboard**: Make sure to process panels in collapsed rows when exporting dashboard [#12256](https://github.com/grafana/grafana/issues/12256) + +### 5.2.0-beta1 fixes + +* **Dashboard**: Dashboard link doesn't work when "As dropdown" option is checked [#12315](https://github.com/grafana/grafana/issues/12315) +* **Dashboard**: Fix regressions after save modal changes, including adhoc template issues [#12240](https://github.com/grafana/grafana/issues/12240) +* **Docker**: Config keys ending with _FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170) # 5.2.0-beta1 (2018-06-05) @@ -62,6 +92,10 @@ * **Dashboard list panel**: Search dashboards by folder [#11525](https://github.com/grafana/grafana/issues/11525) * **Sidenav**: Always show server admin link in sidenav if grafana admin [#11657](https://github.com/grafana/grafana/issues/11657) +# 5.1.4 (2018-06-19) + +* **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343) + # 5.1.3 (2018-05-16) * **Scroll**: Graph panel / legend texts shifts on the left each time we move scrollbar on firefox [#11830](https://github.com/grafana/grafana/issues/11830) diff --git a/ROADMAP.md b/ROADMAP.md index 7b9c043fef1..6f8111fd2d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,28 +1,21 @@ -# Roadmap (2018-05-06) +# Roadmap (2018-06-26) This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. But it will give you an idea of our current vision and plan. ### Short term (1-2 months) - - - Elasticsearch alerting - - Crossplatform builds - - Backend service refactorings - - Explore UI - - First login registration view - -### Mid term (2-4 months) - Multi-Stat panel + - Metrics & Log Explore UI + +### Mid term (2-4 months) - React Panels + - Change visualization (panel type) on the fly. - Templating Query Editor UI Plugin hook ### Long term (4 - 8 months) - Alerting improvements (silence, per series tracking, etc) - Progress on React migration -- Change visualization (panel type) on the fly. -- Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) -- Repeat panel by query results ### In a distant future far far away diff --git a/conf/provisioning/datasources/sample.yaml b/conf/provisioning/datasources/sample.yaml index 877e229183d..37487dc4b3b 100644 --- a/conf/provisioning/datasources/sample.yaml +++ b/conf/provisioning/datasources/sample.yaml @@ -40,11 +40,14 @@ apiVersion: 1 # graphiteVersion: "1.1" # tlsAuth: true # tlsAuthWithCACert: true +# httpHeaderName1: "Authorization" # # json object of data that will be encrypted. # secureJsonData: # tlsCACert: "..." # tlsClientCert: "..." # tlsClientKey: "..." +# # +# httpHeaderValue1: "Bearer xf5yhfkpsnmgo" # version: 1 # # allow users to edit datasources from the UI. # editable: false diff --git a/devenv/dashboards/dev-dashboards/dashboard_with_rows.json b/devenv/dashboards/dev-dashboards/dashboard_with_rows.json new file mode 100644 index 00000000000..335c27bc80a --- /dev/null +++ b/devenv/dashboards/dev-dashboards/dashboard_with_rows.json @@ -0,0 +1,592 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 59, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 9, + "panels": [], + "title": "Row title", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 4, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 7, + "panels": [], + "title": "Row", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 4, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 13, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 11, + "panels": [], + "title": "Row title", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 4, + "w": 12, + "x": 0, + "y": 11 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 11 + }, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "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": "Dashboard with rows", + "uid": "1DdOzBNmk", + "version": 5 +} diff --git a/devenv/dashboards/dev-dashboards/dev-dashboards.yaml b/devenv/dashboards/dev-dashboards/dev-dashboards.yaml new file mode 100644 index 00000000000..343910de738 --- /dev/null +++ b/devenv/dashboards/dev-dashboards/dev-dashboards.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: 'dev dashboards' + folder: 'dev dashboards' + type: file + options: + path: devenv/dashboards/dev-dashboards + diff --git a/devenv/setup.sh b/devenv/setup.sh index c75973ae3ce..0900b57e583 100755 --- a/devenv/setup.sh +++ b/devenv/setup.sh @@ -2,41 +2,43 @@ bulkDashboard() { - requiresJsonnet + requiresJsonnet - COUNTER=0 - MAX=400 - while [ $COUNTER -lt $MAX ]; do - jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + { uid: 'uid-${COUNTER}', title: 'title-${COUNTER}' }" - let COUNTER=COUNTER+1 - done + COUNTER=0 + MAX=400 + while [ $COUNTER -lt $MAX ]; do + jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + { uid: 'uid-${COUNTER}', title: 'title-${COUNTER}' }" + let COUNTER=COUNTER+1 + done - ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml + ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml } requiresJsonnet() { - if ! type "jsonnet" > /dev/null; then - echo "you need you install jsonnet to run this script" - echo "follow the instructions on https://github.com/google/jsonnet" - exit 1 - fi + if ! type "jsonnet" > /dev/null; then + echo "you need you install jsonnet to run this script" + echo "follow the instructions on https://github.com/google/jsonnet" + exit 1 + fi } defaultDashboards() { - echo "not implemented yet" + requiresJsonnet + + ln -s -f -r ./dashboards/dev-dashboards/dev-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml } defaultDatasources() { - echo "setting up all default datasources using provisioning" + echo "setting up all default datasources using provisioning" - ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml + ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml } usage() { echo -e "install.sh\n\tThis script installs my basic setup for a debian laptop\n" echo "Usage:" echo " bulk-dashboards - create and provisioning 400 dashboards" - echo " default-datasources - provisiong all core datasources" + echo " default-datasources - provisiong all core datasources" } main() { @@ -49,10 +51,10 @@ main() { if [[ $cmd == "bulk-dashboards" ]]; then bulkDashboard - elif [[ $cmd == "default-datasources" ]]; then + elif [[ $cmd == "default-datasources" ]]; then defaultDatasources - elif [[ $cmd == "default-dashboards" ]]; then - bulkDashboard + elif [[ $cmd == "default-dashboards" ]]; then + defaultDashboards else usage fi diff --git a/docker/blocks/openldap/Dockerfile b/docker/blocks/openldap/Dockerfile index 54e383a6a97..c9b928ad56a 100644 --- a/docker/blocks/openldap/Dockerfile +++ b/docker/blocks/openldap/Dockerfile @@ -1,3 +1,5 @@ +# Fork of https://github.com/dinkel/docker-openldap + FROM debian:jessie LABEL maintainer="Christian Luginbühl " diff --git a/docker/blocks/openldap/notes.md b/docker/blocks/openldap/notes.md index 71813c2899a..483266f0d88 100644 --- a/docker/blocks/openldap/notes.md +++ b/docker/blocks/openldap/notes.md @@ -11,3 +11,14 @@ After adding ldif files to `prepopulate`: 1. Remove your current docker image: `docker rm docker_openldap_1` 2. Build: `docker-compose build` 3. `docker-compose up` + +## Enabling LDAP in Grafana + +The default `ldap.toml` file in `conf` has host set to `127.0.0.1` and port to set to 389 so all you need to do is enable it in the .ini file to get Grafana to use this block: + +```ini +[auth.ldap] +enabled = true +config_file = conf/ldap.toml +; allow_sign_up = true +``` diff --git a/docs/sources/features/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md index 1426f55e40b..bc96190e9b1 100644 --- a/docs/sources/features/datasources/influxdb.md +++ b/docs/sources/features/datasources/influxdb.md @@ -188,7 +188,7 @@ queries via the Dashboard menu / Annotations view. An example query: ```SQL -SELECT title, description from events WHERE $timeFilter order asc +SELECT title, description from events WHERE $timeFilter ORDER BY time ASC ``` For InfluxDB you need to enter a query like in the above example. You need to have the ```where $timeFilter``` diff --git a/docs/sources/guides/whats-new-in-v5-2.md b/docs/sources/guides/whats-new-in-v5-2.md index 8cff353ff45..554f8f073d8 100644 --- a/docs/sources/guides/whats-new-in-v5-2.md +++ b/docs/sources/guides/whats-new-in-v5-2.md @@ -17,9 +17,11 @@ Grafana v5.2 brings new features, many enhancements and bug fixes. This article * [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here! * [Cross platform build support]({{< relref "#cross-platform-build-support" >}}) enables native builds of Grafana for many more platforms! * [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets +* [Security]({{< relref "#security" >}}) make your Grafana instance more secure * [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements +* [InfluxDB]({{< relref "#influxdb" >}}) with support for a new function * [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord -* [Dashboards & Panels]({{< relref "#dashboards-panels" >}}) +* [Dashboards & Panels]({{< relref "#dashboards-panels" >}}) with save & import enhancements ## Elasticsearch alerting @@ -42,11 +44,24 @@ We've been longing for native ARM build support for a long time. With the help f The Grafana docker image now includes support for Docker secrets which enables you to supply Grafana with configuration through files. More information in the [Installing using Docker documentation](/installation/docker/#reading-secrets-from-files-support-for-docker-secrets). +## Security + +{{< docs-imagebox img="/img/docs/v52/login_change_password.png" max-width="800px" class="docs-image--right" >}} + +Starting from Grafana v5.2, when you login with the administrator account using the default password you'll be presented with a form to change the password. +By this we hope to encourage users to follow Grafana's best practices and change the default administrator password. + +
+ ## Prometheus The Prometheus datasource now aligns the start/end of the query sent to Prometheus with the step, which ensures PromQL expressions with *rate* functions get consistent results, and thus avoid graphs jumping around on reload. +## InfluxDB + +The InfluxDB datasource now includes support for the *mode* function which allows to return the most frequent value in a list of field values. + ## Alerting By popular demand Grafana now includes support for an alert notification channel type for [Discord](https://discordapp.com/). @@ -64,6 +79,20 @@ when you actually want to overwrite those settings.
+### Import dashboard enhancements + +{{< docs-imagebox img="/img/docs/v52/dashboard_import.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.2 adds support for specifying an existing folder or create a new one when importing a dashboard, a long awaited feature since +Grafana v5.0 introduced support for dashboard folders and permissions. The import dashboard page have also got some general improvements +and should now make it more clear if a possible import will overwrite an existing dashboard, or not. + +This release also adds some improvements for those users only having editor or admin permissions in certain folders. Now the links to +*Create Dashboard* and *Import Dashboard* is available in side navigation, dashboard search and manage dashboards/folder page for a +user that has editor role in an organization or edit permission in at least one folder. + +
+ ## Changelog Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 3025b2384df..4bb245a586e 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -15,10 +15,9 @@ weight = 1 Description | Download ------------ | ------------- -Stable for Debian-based Linux | [grafana_5.1.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.3_amd64.deb) - +Stable for Debian-based Linux | [x86-64](https://grafana.com/grafana/download?platform=linux) +Stable for Debian-based Linux | [ARM64](https://grafana.com/grafana/download?platform=arm) +Stable for Debian-based Linux | [ARMv7](https://grafana.com/grafana/download?platform=arm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -27,17 +26,18 @@ installation. ```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.3_amd64.deb +wget sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_5.1.3_amd64.deb +sudo dpkg -i grafana_5.1.4_amd64.deb ``` - +sudo dpkg -i grafana_5.1.4_amd64.deb +``` ## APT Repository diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index aad76c3fb2f..1f755625699 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -52,7 +52,7 @@ $ docker run \ ## Running of the master branch For every successful commit we publish a Grafana container to [`grafana/grafana`](https://hub.docker.com/r/grafana/grafana/tags/) and [`grafana/grafana-dev`](https://hub.docker.com/r/grafana/grafana-dev/tags/). In `grafana/grafana` container we will always overwrite the `master` tag with the latest version. In `grafana/grafana-dev` we will include -the git commit in the tag. If you run Grafana master in production we **strongly** recommend that you use the later since different machines might run different version of grafana if they pull the master tag at different times. +the git commit in the tag. If you run Grafana master in production we **strongly** recommend that you use the later since different machines might run different version of grafana if they pull the master tag at different times. ## Installing Plugins for Grafana @@ -137,16 +137,16 @@ docker run -d --user $ID --volume "$PWD/data:/var/lib/grafana" -p 3000:3000 graf ## Reading secrets from files (support for Docker Secrets) -> Available in v5.2.0 and later +> Only available in Grafana v5.2+. It's possible to supply Grafana with configuration through files. This works well with [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/) as the secrets by default gets mapped into `/run/secrets/` of the container. -You can do this with any of the configuration options in conf/grafana.ini by setting `GF___FILE` to the path of the file holding the secret. +You can do this with any of the configuration options in conf/grafana.ini by setting `GF____FILE` to the path of the file holding the secret. Let's say you want to set the admin password this way. - Admin password secret: `/run/secrets/admin_password` -- Environment variable: `GF_SECURITY_ADMIN_PASSWORD_FILE=/run/secrets/admin_password` +- Environment variable: `GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/admin_password` ## Migration from a previous version of the docker container to 5.1 or later @@ -166,7 +166,7 @@ The docker container for Grafana has seen a major rewrite for 5.1. Previously `/var/lib/grafana`, `/etc/grafana` and `/var/log/grafana` were defined as volumes in the `Dockerfile`. This led to the creation of three volumes each time a new instance of the Grafana container started, whether you wanted it or not. -You should always be careful to define your own named volume for storage, but if you depended on these volumes you should be aware that an upgraded container will no longer have them. +You should always be careful to define your own named volume for storage, but if you depended on these volumes you should be aware that an upgraded container will no longer have them. **Warning**: when migrating from an earlier version to 5.1 or later using docker compose and implicit volumes you need to use `docker inspect` to find out which volumes your container is mapped to so that you can map them to the upgraded container as well. You will also have to change file ownership (or user) as documented below. @@ -201,7 +201,7 @@ services: #### Modifying permissions -The commands below will run bash inside the Grafana container with your volume mapped in. This makes it possible to modify the file ownership to match the new container. Always be careful when modifying permissions. +The commands below will run bash inside the Grafana container with your volume mapped in. This makes it possible to modify the file ownership to match the new container. Always be careful when modifying permissions. ```bash $ docker run -ti --user root --volume "" --entrypoint bash grafana/grafana:5.1.0 diff --git a/docs/sources/installation/mac.md b/docs/sources/installation/mac.md index b1d4f18f699..12ff4adaab9 100644 --- a/docs/sources/installation/mac.md +++ b/docs/sources/installation/mac.md @@ -11,6 +11,8 @@ weight = 4 # Installing on Mac +## Install using homebrew + Installation can be done using [homebrew](http://brew.sh/) Install latest stable: @@ -75,3 +77,18 @@ If you want to manually install a plugin place it here: `/usr/local/var/lib/graf The default sqlite database is located at `/usr/local/var/lib/grafana` +## Installing from binary tar file + +Download [the latest `.tar.gz` file](https://grafana.com/get) and +extract it. This will extract into a folder named after the version you +downloaded. This folder contains all files required to run Grafana. There are +no init scripts or install scripts in this package. + +To configure Grafana add a configuration file named `custom.ini` to the +`conf` folder and override any of the settings defined in +`conf/defaults.ini`. + +Start Grafana by executing `./bin/grafana-server web`. The `grafana-server` +binary needs the working directory to be the root install directory (where the +binary and the `public` folder is located). + diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index e0d808e1f87..13597b9d921 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -15,42 +15,49 @@ weight = 2 Description | Download ------------ | ------------- -Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm) - +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [x86-64](https://grafana.com/grafana/download?platform=linux) +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [ARM64](https://grafana.com/grafana/download?platform=arm) +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [ARMv7](https://grafana.com/grafana/download?platform=arm) -Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing -installation. +Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. ## Install Stable You can install Grafana using Yum directly. ```bash -$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm +$ sudo yum install ``` - +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm +``` -Or install manually using `rpm`. - -#### On CentOS / Fedora / Redhat: +Or install manually using `rpm`. First execute + +```bash +$ wget +``` + +Example: + +```bash +$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm +``` + +### On CentOS / Fedora / Redhat: ```bash -$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm $ sudo yum install initscripts fontconfig -$ sudo rpm -Uvh grafana-5.1.3-1.x86_64.rpm +$ sudo rpm -Uvh ``` -#### On OpenSuse: +### On OpenSuse: ```bash -$ sudo rpm -i --nodeps grafana-5.1.3-1.x86_64.rpm +$ sudo rpm -i --nodeps ``` ## Install via YUM Repository diff --git a/docs/sources/installation/troubleshooting.md b/docs/sources/installation/troubleshooting.md index 12104c6e826..4b777f3248d 100644 --- a/docs/sources/installation/troubleshooting.md +++ b/docs/sources/installation/troubleshooting.md @@ -21,7 +21,7 @@ the data source response. To check this you should use Query Inspector (new in Grafana v4.5). The query Inspector shows query requests and responses. -For more on the query insector read [this guide here](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630). For +For more on the query inspector read [this guide here](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630). For older versions of Grafana read the [how troubleshoot metric query issue](https://community.grafana.com/t/how-to-troubleshoot-metric-query-issues/50/2) article. ## Logging diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 39cdeab6af6..a9a7b5053c3 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -12,11 +12,7 @@ weight = 3 Description | Download ------------ | ------------- -Latest stable package for Windows | [grafana-5.1.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3.windows-x64.zip) - - +Latest stable package for Windows | [x64](https://grafana.com/grafana/download?platform=windows) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/docs/sources/plugins/developing/auth-for-datasources.md b/docs/sources/plugins/developing/auth-for-datasources.md new file mode 100644 index 00000000000..c03793e745f --- /dev/null +++ b/docs/sources/plugins/developing/auth-for-datasources.md @@ -0,0 +1,99 @@ ++++ +title = "Authentication for Datasource Plugins" +type = "docs" +[menu.docs] +name = "Authentication for Datasource Plugins" +parent = "developing" +weight = 3 ++++ + +# Authentication for Datasource Plugins + +Grafana has a proxy feature that proxies all data requests through the Grafana backend. This is very useful when your datasource plugin calls an external/thirdy-party API. The Grafana proxy adds CORS headers and can authenticate against the external API. This means that a datasource plugin that proxies all requests via Grafana can enable token authentication and the token will be renewed automatically for the user when it expires. + +The plugin config page should save the API key/password to be encrypted (using the `secureJsonData` feature) and then when a request from the datasource is made, the Grafana Proxy will: + + 1. decrypt the API key/password on the backend. + 2. carry out authentication and generate an OAuth token that will be added as an `Authorization` HTTP header to all requests (or it will add a HTTP header with the API key). + 3. renew the token if it expires. + +This means that users that access the datasource config page cannot access the API key or password after is saved the first time and that no secret keys are sent in plain text through the browser where they can be spied on. + +For backend authentication to work, the external/third-party API must either have an OAuth endpoint or that the API accepts an API key as a HTTP header for authentication. + +## Plugin Routes + +You can specify routes in the `plugin.json` file for your datasource plugin. [Here is an example](https://github.com/grafana/azure-monitor-datasource/blob/d74c82145c0a4af07a7e96cc8dde231bfd449bd9/src/plugin.json#L30-L95) with lots of routes (though most plugins will just have one route). + +When you build your url to the third-party API in your datasource class, the url should start with the text specified in the path field for a route. The proxy will strip out the path text and replace it with the value in the url field. + +For example, if my code makes a call to url `azuremonitor/foo/bar` with this code: + +```js +this.backendSrv.datasourceRequest({ + url: url, + method: 'GET', +}) +``` + +and this route: + +```json +"routes": [{ + "path": "azuremonitor", + "method": "GET", + "url": "https://management.azure.com", + ... +}] +``` + +then the Grafana proxy will transform it into "https://management.azure.com/foo/bar" and add CORS headers. + +The `method` parameter is optional. It can be set to any HTTP verb to provide more fine-grained control. + +## Encrypting Sensitive Data + +When a user saves a password or secret with your datasource plugin's Config page, then you can save data to a column in the datasource table called `secureJsonData` that is an encrypted blob. Any data saved in the blob is encrypted by Grafana and can only be decrypted by the Grafana server on the backend. This means once a password is saved, no sensitive data is sent to the browser. If the password is saved in the `jsonData` blob or the `password` field then it is unencrypted and anyone with Admin access (with the help of Chrome Developer Tools) can read it. + +This is an example of using the `secureJsonData` blob to save a property called `password`: + +```html + +``` + +## API Key/HTTP Header Authentication + +Some third-party API's accept a HTTP Header for authentication. The [example](https://github.com/grafana/azure-monitor-datasource/blob/d74c82145c0a4af07a7e96cc8dde231bfd449bd9/src/plugin.json#L91-L93) below has a `headers` section that defines the name of the HTTP Header that the API expects and it uses the `SecureJSONData` blob to fetch an encrypted API key. The Grafana server proxy will decrypt the key, add the `X-API-Key` header to the request and forward it to the third-party API. + +```json +{ + "path": "appinsights", + "method": "GET", + "url": "https://api.applicationinsights.io", + "headers": [ + {"name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}"} + ] +} +``` + +## How Token Authentication Works + +The token auth section in the `plugin.json` file looks like this: + +```json +"tokenAuth": { + "url": "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://management.azure.com/" + } +} +``` + +This interpolates in data from both `jsonData` and `secureJsonData` to generate the token request to the third-party API. It is common for tokens to have a short expiry period (30 minutes). The proxy in Grafana server will automatically renew the token if it has expired. + +## Always Restart the Grafana Server After Route Changes + +The plugin.json files are only loaded when the Grafana server starts so when a route is added or changed then the Grafana server has to be restarted for the changes to take effect. diff --git a/docs/sources/plugins/developing/plugin-review-guidelines.md b/docs/sources/plugins/developing/plugin-review-guidelines.md new file mode 100644 index 00000000000..8efb023cf64 --- /dev/null +++ b/docs/sources/plugins/developing/plugin-review-guidelines.md @@ -0,0 +1,175 @@ ++++ +title = "Plugin Review Guidelines" +type = "docs" +[menu.docs] +name = "Plugin Review Guidelines" +parent = "developing" +weight = 2 ++++ + +# Plugin Review Guidelines + +The Grafana team reviews all plugins that are published on Grafana.com. There are two areas we review, the metadata for the plugin and the plugin functionality. + +## Metadata + +The plugin metadata consists of a `plugin.json` file and the README.md file. These `plugin.json` file is used by Grafana to load the plugin and the README.md file is shown in the plugins section of Grafana and the plugins section of Grafana.com. + +### README.md + +The README.md file is shown on the plugins page in Grafana and the plugin page on Grafana.com. There are some differences between the GitHub markdown and the markdown allowed in Grafana/Grafana.com: + +- Cannot contain inline HTML. +- Any image links should be absolute links. For example: https://raw.githubusercontent.com/grafana/azure-monitor-datasource/master/dist/img/grafana_cloud_install.png + +The README should: + +- describe the purpose of the plugin. +- contain steps on how to get started. + +### Plugin.json + +The `plugin.json` file is the same concept as the `package.json` file for an npm package. When the Grafana server starts it will scan the plugin folders (all folders in the data/plugins subfolder) and load every folder that contains a `plugin.json` file unless the folder contains a subfolder named `dist`. In that case, the Grafana server will load the `dist` folder instead. + +A minimal `plugin.json` file: + +```json +{ + "type": "panel", + "name": "Clock", + "id": "yourorg-clock-panel", + + "info": { + "description": "Clock panel for grafana", + "author": { + "name": "Author Name", + "url": "http://yourwebsite.com" + }, + "keywords": ["clock", "panel"], + "version": "1.0.0", + "updated": "2018-03-24" + }, + + "dependencies": { + "grafanaVersion": "3.x.x", + "plugins": [ ] + } +} +``` + +- The convention for the plugin id is [github username/org]-[plugin name]-[datasource|app|panel] and it has to be unique. Although if org and plugin name are the same then [plugin name]-[datasource|app|panel] is also valid. The org **cannot** be `grafana` unless it is a plugin created by the Grafana core team. + + Examples: + + - raintank-worldping-app + - ryantxu-ajax-panel + - alexanderzobnin-zabbix-app + - hawkular-datasource + +- The `type` field should be either `datasource` `app` or `panel`. +- The `version` field should be in the form: x.x.x e.g. `1.0.0` or `0.4.1`. + +The full file format for the `plugin.json` file is described [here](http://docs.grafana.org/plugins/developing/plugin.json/). + +## Plugin Language + +JavaScript, TypeScript, ES6 (or any other language) are all fine as long as the contents of the `dist` subdirectory are transpiled to JavaScript (ES5). + +## File and Directory Structure Conventions + +Here is a typical directory structure for a plugin. + +```bash +johnnyb-awesome-datasource +|-- dist +|-- src +| |-- img +| | |-- logo.svg +| |-- partials +| | |-- annotations.editor.html +| | |-- config.html +| | |-- query.editor.html +| |-- datasource.js +| |-- module.js +| |-- plugin.json +| |-- query_ctrl.js +|-- Gruntfile.js +|-- LICENSE +|-- package.json +|-- README.md +``` + +Most JavaScript projects have a build step. The generated JavaScript should be placed in the `dist` directory and the source code in the `src` directory. We recommend that the plugin.json file be placed in the src directory and then copied over to the dist directory when building. The `README.md` can be placed in the root or in the dist directory. + +Directories: + +- `src/` contains plugin source files. +- `src/partials` contains html templates. +- `src/img` contains plugin logos and other images. +- `dist/` contains built content. + +## HTML and CSS + +For the HTML on editor tabs, we recommend using the inbuilt Grafana styles rather than defining your own. This makes plugins feel like a more natural part of Grafana. If done correctly, the html will also be responsive and adapt to smaller screens. The `gf-form` css classes should be used for labels and inputs. + +Below is a minimal example of an editor row with one form group and two fields, a dropdown and a text input: + +```html +
+
+
My Plugin Options
+
+ +
+ +
+
+ + +
+
+
+
+``` + +Use the `width-x` and `max-width-x` classes to control the width of your labels and input fields. Try to get labels and input fields to line up neatly by having the same width for all the labels in a group and the same width for all inputs in a group if possible. + +## Data Sources + +A basic guide for data sources can be found [here](http://docs.grafana.org/plugins/developing/datasources/). + +### Config Page Guidelines + +- It should be as easy as possible for a user to configure a url. If the data source is using the `datasource-http-settings` component, it should use the `suggest-url` attribute to suggest the default url or a url that is similar to what it should be (especially important if the url refers to a REST endpoint that is not common knowledge for most users e.g. `https://yourserver:4000/api/custom-endpoint`). + + ```html + + + ``` + +- The `testDatasource` function should make a query to the data source that will also test that the authentication details are correct. This is so the data source is correctly configured when the user tries to write a query in a new dashboard. + +#### Password Security + +If possible, any passwords or secrets should be be saved in the `secureJsonData` blob. To encrypt sensitive data, the Grafana server's proxy feature must be used. The Grafana server has support for token authentication (OAuth) and HTTP Header authentication. If the calls have to be sent directly from the browser to a third-party API then this will not be possible and sensitive data will not be encrypted. + +Read more here about how [Authentication for Datasources]({{< relref "auth-for-datasources.md" >}}) works. + +If using the proxy feature then the Config page should use the `secureJsonData` blob like this: + + - good: `` + - bad: `` + +### Query Editor + +Each query editor is unique and can have a unique style. It should be adapted to what the users of the data source are used to. + +- Should use the Grafana CSS `gf-form` classes. +- Should be neat and tidy. Labels and fields in columns should be aligned and should be the same width if possible. +- The datasource should be able to handle when a user toggles a query (by clicking on the eye icon) and not execute the query. This is done by checking the `hide` property - an [example](https://github.com/grafana/grafana/blob/master/public/app/plugins/datasource/postgres/datasource.ts#L35-L38). +- Should not execute queries if fields in the Query Editor are empty and the query will throw an exception (defensive programming). +- Should handle errors. There are two main ways to do this: + - use the notification system in Grafana to show a toaster popup with the error message. Example [here](https://github.com/alexanderzobnin/grafana-zabbix/blob/fdbbba2fb03f5f2a4b3b0715415e09d5a4cf6cde/src/panel-triggers/triggers_panel_ctrl.js#L467-L471). + - provide an error notification in the query editor like the MySQL/Postgres data sources do. Example code in the `query_ctrl` [here](https://github.com/grafana/azure-monitor-datasource/blob/b184d077f082a69f962120ef0d1f8296a0d46f03/src/query_ctrl.ts#L36-L51) and in the [html](https://github.com/grafana/azure-monitor-datasource/blob/b184d077f082a69f962120ef0d1f8296a0d46f03/src/partials/query.editor.html#L190-L193). diff --git a/karma.conf.js b/karma.conf.js index 3f006af08b6..352e8e4e027 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -19,8 +19,8 @@ module.exports = function(config) { }, webpack: webpackTestConfig, - webpackServer: { - noInfo: true, // please don't spam the console when running in karma! + webpackMiddleware: { + stats: 'minimal', }, // list of files to exclude diff --git a/package.json b/package.json index df3da5812c1..0433dc3e986 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,11 @@ "@types/node": "^8.0.31", "@types/react": "^16.0.25", "@types/react-dom": "^16.0.3", - "angular-mocks": "^1.6.6", + "angular-mocks": "1.6.6", "autoprefixer": "^6.4.0", - "awesome-typescript-loader": "^4.0.0", "axios": "^0.17.1", "babel-core": "^6.26.0", + "babel-loader": "^7.1.4", "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-preset-es2015": "^6.24.1", "clean-webpack-plugin": "^0.1.19", @@ -32,8 +32,9 @@ "es6-shim": "^0.35.3", "expect.js": "~0.2.0", "expose-loader": "^0.7.3", - "extract-text-webpack-plugin": "^3.0.0", + "extract-text-webpack-plugin": "^4.0.0-beta.0", "file-loader": "^1.1.11", + "fork-ts-checker-webpack-plugin": "^0.4.1", "gaze": "^1.1.2", "glob": "~7.0.0", "grunt": "1.0.1", @@ -56,7 +57,7 @@ "grunt-webpack": "^3.0.2", "html-loader": "^0.5.1", "html-webpack-harddisk-plugin": "^0.2.0", - "html-webpack-plugin": "^2.30.1", + "html-webpack-plugin": "^3.2.0", "husky": "^0.14.3", "jest": "^22.0.4", "jshint-stylish": "~2.2.1", @@ -67,7 +68,7 @@ "karma-phantomjs-launcher": "1.0.4", "karma-sinon": "^1.0.5", "karma-sourcemap-loader": "^0.3.7", - "karma-webpack": "^2.0.4", + "karma-webpack": "^3.0.0", "lint-staged": "^6.0.0", "load-grunt-tasks": "3.5.2", "mobx-react-devtools": "^4.2.15", @@ -89,21 +90,24 @@ "style-loader": "^0.21.0", "systemjs": "0.20.19", "systemjs-plugin-css": "^0.1.36", - "ts-jest": "^22.0.0", + "ts-loader": "^4.3.0", + "ts-jest": "^22.4.6", "tslint": "^5.8.0", "tslint-loader": "^3.5.3", "typescript": "^2.6.2", - "webpack": "^3.10.0", + "webpack": "^4.8.0", "webpack-bundle-analyzer": "^2.9.0", "webpack-cleanup-plugin": "^0.5.1", - "webpack-dev-server": "2.11.1", + "fork-ts-checker-webpack-plugin": "^0.4.2", + "webpack-cli": "^2.1.4", + "webpack-dev-server": "^3.1.0", "webpack-merge": "^4.1.0", "zone.js": "^0.7.2" }, "scripts": { - "dev": "webpack --progress --colors --config scripts/webpack/webpack.dev.js", - "start": "webpack-dev-server --progress --colors --config scripts/webpack/webpack.hot.js", - "watch": "webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js", + "dev": "webpack --progress --colors --mode development --config scripts/webpack/webpack.dev.js", + "start": "webpack-dev-server --progress --colors --mode development --config scripts/webpack/webpack.hot.js", + "watch": "webpack --progress --colors --watch --mode development --config scripts/webpack/webpack.dev.js", "build": "grunt build", "test": "grunt test", "test:coverage": "grunt test --coverage=true", @@ -135,8 +139,8 @@ "license": "Apache-2.0", "dependencies": { "angular": "1.6.6", - "angular-bindonce": "^0.3.1", - "angular-native-dragdrop": "^1.2.2", + "angular-bindonce": "0.3.1", + "angular-native-dragdrop": "1.2.2", "angular-route": "1.6.6", "angular-sanitize": "1.6.6", "babel-polyfill": "^6.26.0", @@ -151,12 +155,14 @@ "immutable": "^3.8.2", "jquery": "^3.2.1", "lodash": "^4.17.4", + "mini-css-extract-plugin": "^0.4.0", "mobx": "^3.4.1", "mobx-react": "^4.3.5", "mobx-state-tree": "^1.3.1", "moment": "^2.18.1", "mousetrap": "^1.6.0", "mousetrap-global-bind": "^1.1.0", + "optimize-css-assets-webpack-plugin": "^4.0.2", "prismjs": "^1.6.0", "prop-types": "^15.6.0", "react": "^16.2.0", @@ -175,7 +181,8 @@ "slate-react": "^0.12.4", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop/tarball/master", - "tinycolor2": "^1.4.1" + "tinycolor2": "^1.4.1", + "uglifyjs-webpack-plugin": "^1.2.7" }, "resolutions": { "caniuse-db": "1.0.30000772" diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 52eeb57dbb9..55c9c954940 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -37,7 +37,6 @@ func GetAnnotations(c *m.ReqContext) Response { if item.Email != "" { item.AvatarUrl = dtos.GetGravatarUrl(item.Email) } - item.Time = item.Time } return JSON(200, items) @@ -214,7 +213,9 @@ func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response repo := annotations.GetRepository() err := repo.Delete(&annotations.DeleteParams{ - AlertId: cmd.PanelId, + OrgId: c.OrgId, + Id: cmd.AnnotationId, + RegionId: cmd.RegionId, DashboardId: cmd.DashboardId, PanelId: cmd.PanelId, }) @@ -235,7 +236,8 @@ func DeleteAnnotationByID(c *m.ReqContext) Response { } err := repo.Delete(&annotations.DeleteParams{ - Id: annotationID, + OrgId: c.OrgId, + Id: annotationID, }) if err != nil { @@ -254,6 +256,7 @@ func DeleteAnnotationRegion(c *m.ReqContext) Response { } err := repo.Delete(&annotations.DeleteParams{ + OrgId: c.OrgId, RegionId: regionID, }) @@ -269,9 +272,9 @@ func canSaveByDashboardID(c *m.ReqContext, dashboardID int64) (bool, error) { return false, nil } - if dashboardID > 0 { - guardian := guardian.New(dashboardID, c.OrgId, c.SignedInUser) - if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { + if dashboardID != 0 { + guard := guardian.New(dashboardID, c.OrgId, c.SignedInUser) + if canEdit, err := guard.CanEdit(); err != nil || !canEdit { return false, err } } diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 9fe96245b9b..e5f63ce022b 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -100,6 +100,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { Id: 1, } + deleteCmd := dtos.DeleteAnnotationsCmd{ + DashboardId: 1, + PanelId: 1, + } + viewerRole := m.ROLE_VIEWER editorRole := m.ROLE_EDITOR @@ -171,6 +176,25 @@ func TestAnnotationsApiEndpoint(t *testing.T) { }) }) }) + + Convey("When user is an Admin", func() { + role := m.ROLE_ADMIN + Convey("Should be able to do anything", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + deleteAnnotationsScenario("When calling POST on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) }) } @@ -239,3 +263,26 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m. fn(sc) }) } + +func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return DeleteAnnotations(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index c205e7d3e2f..39dae56eb69 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -4,6 +4,7 @@ import ( "github.com/go-macaron/binding" "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" ) @@ -117,10 +118,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/api/login/ping", quota("session"), LoginAPIPing) // authed api - r.Group("/api", func(apiRoute RouteRegister) { + r.Group("/api", func(apiRoute routing.RouteRegister) { // user (signed in) - apiRoute.Group("/user", func(userRoute RouteRegister) { + apiRoute.Group("/user", func(userRoute routing.RouteRegister) { userRoute.Get("/", wrap(GetSignedInUser)) userRoute.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser)) userRoute.Post("/using/:id", wrap(UserSetUsingOrg)) @@ -140,7 +141,7 @@ func (hs *HTTPServer) registerRoutes() { }) // users (admin permission required) - apiRoute.Group("/users", func(usersRoute RouteRegister) { + apiRoute.Group("/users", func(usersRoute routing.RouteRegister) { usersRoute.Get("/", wrap(SearchUsers)) usersRoute.Get("/search", wrap(SearchUsersWithPaging)) usersRoute.Get("/:id", wrap(GetUserByID)) @@ -152,7 +153,7 @@ func (hs *HTTPServer) registerRoutes() { }, reqGrafanaAdmin) // team (admin permission required) - apiRoute.Group("/teams", func(teamsRoute RouteRegister) { + apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam)) teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID)) @@ -162,19 +163,19 @@ func (hs *HTTPServer) registerRoutes() { }, reqOrgAdmin) // team without requirement of user to be org admin - apiRoute.Group("/teams", func(teamsRoute RouteRegister) { + apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Get("/:teamId", wrap(GetTeamByID)) teamsRoute.Get("/search", wrap(SearchTeams)) }) // org information available to all users. - apiRoute.Group("/org", func(orgRoute RouteRegister) { + apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { orgRoute.Get("/", wrap(GetOrgCurrent)) orgRoute.Get("/quotas", wrap(GetOrgQuotas)) }) // current org - apiRoute.Group("/org", func(orgRoute RouteRegister) { + apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrgCurrent)) orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddressCurrent)) orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg)) @@ -192,7 +193,7 @@ func (hs *HTTPServer) registerRoutes() { }, reqOrgAdmin) // current org without requirement of user to be org admin - apiRoute.Group("/org", func(orgRoute RouteRegister) { + apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg)) }) @@ -203,7 +204,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs)) // orgs (admin routes) - apiRoute.Group("/orgs/:orgId", func(orgsRoute RouteRegister) { + apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) { orgsRoute.Get("/", wrap(GetOrgByID)) orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrg)) orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddress)) @@ -217,24 +218,24 @@ func (hs *HTTPServer) registerRoutes() { }, reqGrafanaAdmin) // orgs (admin routes) - apiRoute.Group("/orgs/name/:name", func(orgsRoute RouteRegister) { + apiRoute.Group("/orgs/name/:name", func(orgsRoute routing.RouteRegister) { orgsRoute.Get("/", wrap(GetOrgByName)) }, reqGrafanaAdmin) // auth api keys - apiRoute.Group("/auth/keys", func(keysRoute RouteRegister) { + apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { keysRoute.Get("/", wrap(GetAPIKeys)) keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddAPIKey)) keysRoute.Delete("/:id", wrap(DeleteAPIKey)) }, reqOrgAdmin) // Preferences - apiRoute.Group("/preferences", func(prefRoute RouteRegister) { + apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) { prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), wrap(SetHomeDashboard)) }) // Data sources - apiRoute.Group("/datasources", func(datasourceRoute RouteRegister) { + apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) { datasourceRoute.Get("/", wrap(GetDataSources)) datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), wrap(AddDataSource)) datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource)) @@ -250,7 +251,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/plugins/:pluginId/settings", wrap(GetPluginSettingByID)) apiRoute.Get("/plugins/:pluginId/markdown/:name", wrap(GetPluginMarkdown)) - apiRoute.Group("/plugins", func(pluginRoute RouteRegister) { + apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { pluginRoute.Get("/:pluginId/dashboards/", wrap(GetPluginDashboards)) pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), wrap(UpdatePluginSetting)) }, reqOrgAdmin) @@ -260,17 +261,17 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) // Folders - apiRoute.Group("/folders", func(folderRoute RouteRegister) { + apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { folderRoute.Get("/", wrap(GetFolders)) folderRoute.Get("/id/:id", wrap(GetFolderByID)) folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) - folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) { + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { folderUidRoute.Get("/", wrap(GetFolderByUID)) folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) folderUidRoute.Delete("/", wrap(DeleteFolder)) - folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) { + folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { folderPermissionRoute.Get("/", wrap(GetFolderPermissionList)) folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions)) }) @@ -278,7 +279,7 @@ func (hs *HTTPServer) registerRoutes() { }) // Dashboard - apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { + apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUID)) @@ -292,12 +293,12 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) - dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { + dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) { dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) - dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) { + dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) { dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList)) dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions)) }) @@ -305,12 +306,12 @@ func (hs *HTTPServer) registerRoutes() { }) // Dashboard snapshots - apiRoute.Group("/dashboard/snapshots", func(dashboardRoute RouteRegister) { + apiRoute.Group("/dashboard/snapshots", func(dashboardRoute routing.RouteRegister) { dashboardRoute.Get("/", wrap(SearchDashboardSnapshots)) }) // Playlist - apiRoute.Group("/playlists", func(playlistRoute RouteRegister) { + apiRoute.Group("/playlists", func(playlistRoute routing.RouteRegister) { playlistRoute.Get("/", wrap(SearchPlaylists)) playlistRoute.Get("/:id", ValidateOrgPlaylist, wrap(GetPlaylist)) playlistRoute.Get("/:id/items", ValidateOrgPlaylist, wrap(GetPlaylistItems)) @@ -329,7 +330,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, wrap(GenerateSQLTestData)) apiRoute.Get("/tsdb/testdata/random-walk", wrap(GetTestDataRandomWalk)) - apiRoute.Group("/alerts", func(alertsRoute RouteRegister) { + apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) { alertsRoute.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest)) alertsRoute.Post("/:alertId/pause", reqEditorRole, bind(dtos.PauseAlertCommand{}), wrap(PauseAlert)) alertsRoute.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert)) @@ -340,7 +341,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/alert-notifications", wrap(GetAlertNotifications)) apiRoute.Get("/alert-notifiers", wrap(GetAlertNotifiers)) - apiRoute.Group("/alert-notifications", func(alertNotifications RouteRegister) { + apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) { alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), wrap(NotificationTest)) alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), wrap(CreateAlertNotification)) alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), wrap(UpdateAlertNotification)) @@ -351,7 +352,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/annotations", wrap(GetAnnotations)) apiRoute.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations)) - apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) { + apiRoute.Group("/annotations", func(annotationsRoute routing.RouteRegister) { annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation)) annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationByID)) annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation)) @@ -365,7 +366,7 @@ func (hs *HTTPServer) registerRoutes() { }, reqSignedIn) // admin api - r.Group("/api/admin", func(adminRoute RouteRegister) { + r.Group("/api/admin", func(adminRoute routing.RouteRegister) { adminRoute.Get("/settings", AdminGetSettings) adminRoute.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser) adminRoute.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 99677a93ee6..6ffefea991a 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -103,6 +103,9 @@ func DeleteDataSourceByName(c *m.ReqContext) Response { getCmd := &m.GetDataSourceByNameQuery{Name: name, OrgId: c.OrgId} if err := bus.Dispatch(getCmd); err != nil { + if err == m.ErrDataSourceNotFound { + return Error(404, "Data source not found", nil) + } return Error(500, "Failed to delete datasource", err) } diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 490393727d6..6e52a27758b 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -46,5 +46,13 @@ func TestDataSourcesProxy(t *testing.T) { So(respJSON[3]["name"], ShouldEqual, "ZZZ") }) }) + + Convey("Should be able to save a data source", func() { + loggedInUserScenario("When calling DELETE on non-existing", "/api/datasources/name/12345", func(sc *scenarioContext) { + sc.handlerFunc = DeleteDataSourceByName + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + }) + }) }) } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 371c500a73e..e1a10fb468f 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -11,6 +11,7 @@ import ( "path" "time" + "github.com/grafana/grafana/pkg/api/routing" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -43,10 +44,10 @@ type HTTPServer struct { cache *gocache.Cache httpSrv *http.Server - RouteRegister RouteRegister `inject:""` - Bus bus.Bus `inject:""` - RenderService rendering.Service `inject:""` - Cfg *setting.Cfg `inject:""` + RouteRegister routing.RouteRegister `inject:""` + Bus bus.Bus `inject:""` + RenderService rendering.Service `inject:""` + Cfg *setting.Cfg `inject:""` } func (hs *HTTPServer) Init() error { diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 95e023efe8f..b420398f9a9 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -117,6 +117,28 @@ func (proxy *DataSourceProxy) addTraceFromHeaderValue(span opentracing.Span, hea } } +func (proxy *DataSourceProxy) useCustomHeaders(req *http.Request) { + decryptSdj := proxy.ds.SecureJsonData.Decrypt() + index := 1 + for { + headerNameSuffix := fmt.Sprintf("httpHeaderName%d", index) + headerValueSuffix := fmt.Sprintf("httpHeaderValue%d", index) + if key := proxy.ds.JsonData.Get(headerNameSuffix).MustString(); key != "" { + if val, ok := decryptSdj[headerValueSuffix]; ok { + // remove if exists + if req.Header.Get(key) != "" { + req.Header.Del(key) + } + req.Header.Add(key, val) + logger.Debug("Using custom header ", "CustomHeaders", key) + } + } else { + break + } + index += 1 + } +} + func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { return func(req *http.Request) { req.URL.Scheme = proxy.targetUrl.Scheme @@ -146,6 +168,11 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.BasicAuthUser, proxy.ds.BasicAuthPassword)) } + // Lookup and use custom headers + if proxy.ds.SecureJsonData != nil { + proxy.useCustomHeaders(req) + } + dsAuth := req.Header.Get("X-DS-Authorization") if len(dsAuth) > 0 { req.Header.Del("X-DS-Authorization") diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 615a64c7bea..bb553b4d075 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -12,6 +12,7 @@ import ( macaron "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" @@ -322,6 +323,37 @@ func TestDSRouteRule(t *testing.T) { So(interpolated, ShouldEqual, "0asd+asd") }) + Convey("When proxying a data source with custom headers specified", func() { + plugin := &plugins.DataSourcePlugin{} + + encryptedData, err := util.Encrypt([]byte(`Bearer xf5yhfkpsnmgo`), setting.SecretKey) + ds := &m.DataSource{ + Type: m.DS_PROMETHEUS, + Url: "http://prometheus:9090", + JsonData: simplejson.NewFromAny(map[string]interface{}{ + "httpHeaderName1": "Authorization", + }), + SecureJsonData: map[string][]byte{ + "httpHeaderValue1": encryptedData, + }, + } + + ctx := &m.ReqContext{} + proxy := NewDataSourceProxy(ds, plugin, ctx, "") + + requestURL, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestURL, Header: make(http.Header)} + proxy.getDirector()(&req) + + if err != nil { + log.Fatal(4, err.Error()) + } + + Convey("Match header value after decryption", func() { + So(req.Header.Get("Authorization"), ShouldEqual, "Bearer xf5yhfkpsnmgo") + }) + }) + }) } diff --git a/pkg/api/render.go b/pkg/api/render.go index f6f149980d6..b8ef6cc5cb6 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -3,7 +3,9 @@ package api import ( "fmt" "net/http" + "runtime" "strconv" + "strings" "time" m "github.com/grafana/grafana/pkg/models" @@ -55,6 +57,15 @@ func (hs *HTTPServer) RenderToPng(c *m.ReqContext) { return } + if err != nil && err == rendering.ErrPhantomJSNotInstalled { + if strings.HasPrefix(runtime.GOARCH, "arm") { + c.Handle(500, "Rendering failed - PhantomJS isn't included in arm build per default", err) + } else { + c.Handle(500, "Rendering failed - PhantomJS isn't installed correctly", err) + } + return + } + if err != nil { c.Handle(500, "Rendering failed.", err) return diff --git a/pkg/api/route_register.go b/pkg/api/routing/route_register.go similarity index 68% rename from pkg/api/route_register.go rename to pkg/api/routing/route_register.go index 926de13c546..7a054ad0a24 100644 --- a/pkg/api/route_register.go +++ b/pkg/api/routing/route_register.go @@ -1,9 +1,10 @@ -package api +package routing import ( "net/http" + "strings" - macaron "gopkg.in/macaron.v1" + "gopkg.in/macaron.v1" ) type Router interface { @@ -14,16 +15,34 @@ type Router interface { // RouteRegister allows you to add routes and macaron.Handlers // that the web server should serve. type RouteRegister interface { + // Get adds a list of handlers to a given route with a GET HTTP verb Get(string, ...macaron.Handler) + + // Post adds a list of handlers to a given route with a POST HTTP verb Post(string, ...macaron.Handler) + + // Delete adds a list of handlers to a given route with a DELETE HTTP verb Delete(string, ...macaron.Handler) + + // Put adds a list of handlers to a given route with a PUT HTTP verb Put(string, ...macaron.Handler) + + // Patch adds a list of handlers to a given route with a PATCH HTTP verb Patch(string, ...macaron.Handler) + + // Any adds a list of handlers to a given route with any HTTP verb Any(string, ...macaron.Handler) + // Group allows you to pass a function that can add multiple routes + // with a shared prefix route. Group(string, func(RouteRegister), ...macaron.Handler) - Register(Router) *macaron.Router + // Insert adds more routes to an existing Group. + Insert(string, func(RouteRegister), ...macaron.Handler) + + // Register iterates over all routes added to the RouteRegister + // and add them to the `Router` pass as an parameter. + Register(Router) } type RegisterNamedMiddleware func(name string) macaron.Handler @@ -52,6 +71,24 @@ type routeRegister struct { groups []*routeRegister } +func (rr *routeRegister) Insert(pattern string, fn func(RouteRegister), handlers ...macaron.Handler) { + + //loop over all groups at current level + for _, g := range rr.groups { + + // apply routes if the prefix matches the pattern + if g.prefix == pattern { + g.Group("", fn) + break + } + + // go down one level if the prefix can be find in the pattern + if strings.HasPrefix(pattern, g.prefix) { + g.Insert(pattern, fn) + } + } +} + func (rr *routeRegister) Group(pattern string, fn func(rr RouteRegister), handlers ...macaron.Handler) { group := &routeRegister{ prefix: rr.prefix + pattern, @@ -64,7 +101,7 @@ func (rr *routeRegister) Group(pattern string, fn func(rr RouteRegister), handle rr.groups = append(rr.groups, group) } -func (rr *routeRegister) Register(router Router) *macaron.Router { +func (rr *routeRegister) Register(router Router) { for _, r := range rr.routes { // GET requests have to be added to macaron routing using Get() // Otherwise HEAD requests will not be allowed. @@ -79,8 +116,6 @@ func (rr *routeRegister) Register(router Router) *macaron.Router { for _, g := range rr.groups { g.Register(router) } - - return &macaron.Router{} } func (rr *routeRegister) route(pattern, method string, handlers ...macaron.Handler) { @@ -92,6 +127,12 @@ func (rr *routeRegister) route(pattern, method string, handlers ...macaron.Handl h = append(h, rr.subfixHandlers...) h = append(h, handlers...) + for _, r := range rr.routes { + if r.pattern == rr.prefix+pattern && r.method == method { + panic("cannot add duplicate route") + } + } + rr.routes = append(rr.routes, route{ method: method, pattern: rr.prefix + pattern, diff --git a/pkg/api/route_register_test.go b/pkg/api/routing/route_register_test.go similarity index 72% rename from pkg/api/route_register_test.go rename to pkg/api/routing/route_register_test.go index 3b5d79599a8..62e8989ff92 100644 --- a/pkg/api/route_register_test.go +++ b/pkg/api/routing/route_register_test.go @@ -1,11 +1,11 @@ -package api +package routing import ( "net/http" "strconv" "testing" - macaron "gopkg.in/macaron.v1" + "gopkg.in/macaron.v1" ) type fakeRouter struct { @@ -33,7 +33,7 @@ func (fr *fakeRouter) Get(pattern string, handlers ...macaron.Handler) *macaron. } func emptyHandlers(n int) []macaron.Handler { - res := []macaron.Handler{} + var res []macaron.Handler for i := 1; n >= i; i++ { res = append(res, emptyHandler(strconv.Itoa(i))) } @@ -138,7 +138,78 @@ func TestRouteGroupedRegister(t *testing.T) { } } } +func TestRouteGroupInserting(t *testing.T) { + testTable := []route{ + {method: http.MethodGet, pattern: "/api/", handlers: emptyHandlers(1)}, + {method: http.MethodPost, pattern: "/api/group/endpoint", handlers: emptyHandlers(1)}, + {method: http.MethodGet, pattern: "/api/group/inserted", handlers: emptyHandlers(1)}, + {method: http.MethodDelete, pattern: "/api/inserted-endpoint", handlers: emptyHandlers(1)}, + } + + // Setup + rr := NewRouteRegister() + + rr.Group("/api", func(api RouteRegister) { + api.Get("/", emptyHandler("1")) + + api.Group("/group", func(group RouteRegister) { + group.Post("/endpoint", emptyHandler("1")) + }) + }) + + rr.Insert("/api", func(api RouteRegister) { + api.Delete("/inserted-endpoint", emptyHandler("1")) + }) + + rr.Insert("/api/group", func(group RouteRegister) { + group.Get("/inserted", emptyHandler("1")) + }) + + fr := &fakeRouter{} + rr.Register(fr) + + // Validation + if len(fr.route) != len(testTable) { + t.Fatalf("want %v routes, got %v", len(testTable), len(fr.route)) + } + + for i := range testTable { + if testTable[i].method != fr.route[i].method { + t.Errorf("want %s got %v", testTable[i].method, fr.route[i].method) + } + + if testTable[i].pattern != fr.route[i].pattern { + t.Errorf("want %s got %v", testTable[i].pattern, fr.route[i].pattern) + } + + if len(testTable[i].handlers) != len(fr.route[i].handlers) { + t.Errorf("want %d handlers got %d handlers \ntestcase: %v\nroute: %v\n", + len(testTable[i].handlers), + len(fr.route[i].handlers), + testTable[i], + fr.route[i]) + } + } +} + +func TestDuplicateRoutShouldPanic(t *testing.T) { + defer func() { + if recover() != "cannot add duplicate route" { + t.Errorf("Should cause panic if duplicate routes are added ") + } + }() + + rr := NewRouteRegister(func(name string) macaron.Handler { + return emptyHandler(name) + }) + + rr.Get("/api", emptyHandler("1")) + rr.Get("/api", emptyHandler("1")) + + fr := &fakeRouter{} + rr.Register(fr) +} func TestNamedMiddlewareRouteRegister(t *testing.T) { testTable := []route{ {method: "DELETE", pattern: "/admin", handlers: emptyHandlers(2)}, diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 4abdb3fb442..6444528f7f0 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -12,6 +12,7 @@ import ( "time" "github.com/facebookgo/inject" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/registry" @@ -61,8 +62,8 @@ type GrafanaServerImpl struct { shutdownReason string shutdownInProgress bool - RouteRegister api.RouteRegister `inject:""` - HttpServer *api.HTTPServer `inject:""` + RouteRegister routing.RouteRegister `inject:""` + HttpServer *api.HTTPServer `inject:""` } func (g *GrafanaServerImpl) Run() error { @@ -75,7 +76,7 @@ func (g *GrafanaServerImpl) Run() error { serviceGraph := inject.Graph{} serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) serviceGraph.Provide(&inject.Object{Value: g.cfg}) - serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) + serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) // self registered services services := registry.GetServices() diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 3c5001df3a4..026a94fa43e 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -308,6 +308,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { } else { filter_replace = getLdapAttr(a.server.GroupSearchFilterUserAttribute, searchResult) } + filter := strings.Replace(a.server.GroupSearchFilter, "%s", ldap.EscapeFilter(filter_replace), -1) a.log.Info("Searching for user's groups", "filter", filter) @@ -348,7 +349,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { } func getLdapAttrN(name string, result *ldap.SearchResult, n int) string { - if name == "DN" { + if strings.ToLower(name) == "dn" { return result.Entries[n].DN } for _, attr := range result.Entries[n].Attributes { diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index 5cebb3d2df9..9b490169d3b 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -35,11 +35,12 @@ type PostParams struct { } type DeleteParams struct { - Id int64 `json:"id"` - AlertId int64 `json:"alertId"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - RegionId int64 `json:"regionId"` + OrgId int64 + Id int64 + AlertId int64 + DashboardId int64 + PanelId int64 + RegionId int64 } var repositoryInstance Repository diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index bf455adc7ca..cfd8f5c3a6e 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -83,7 +83,7 @@ func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.D for _, p := range acl { // user match - if !g.user.IsAnonymous { + if !g.user.IsAnonymous && p.UserId > 0 { if p.UserId == g.user.UserId && p.Permission >= permission { return true, nil } diff --git a/pkg/services/guardian/guardian_test.go b/pkg/services/guardian/guardian_test.go index 5e56b1d88c3..4704519b38d 100644 --- a/pkg/services/guardian/guardian_test.go +++ b/pkg/services/guardian/guardian_test.go @@ -28,7 +28,7 @@ func TestGuardianAdmin(t *testing.T) { Convey("Guardian admin org role tests", t, func() { orgRoleScenario("Given user has admin org role", t, m.ROLE_ADMIN, func(sc *scenarioContext) { // dashboard has default permissions - sc.defaultPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.defaultPermissionScenario(USER, FULL_ACCESS) // dashboard has user with permission sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) @@ -76,6 +76,9 @@ func TestGuardianAdmin(t *testing.T) { func TestGuardianEditor(t *testing.T) { Convey("Guardian editor org role tests", t, func() { orgRoleScenario("Given user has editor org role", t, m.ROLE_EDITOR, func(sc *scenarioContext) { + // dashboard has default permissions + sc.defaultPermissionScenario(USER, EDITOR_ACCESS) + // dashboard has user with permission sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS) @@ -122,6 +125,9 @@ func TestGuardianEditor(t *testing.T) { func TestGuardianViewer(t *testing.T) { Convey("Guardian viewer org role tests", t, func() { orgRoleScenario("Given user has viewer org role", t, m.ROLE_VIEWER, func(sc *scenarioContext) { + // dashboard has default permissions + sc.defaultPermissionScenario(USER, VIEWER_ACCESS) + // dashboard has user with permission sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS) @@ -162,10 +168,15 @@ func TestGuardianViewer(t *testing.T) { sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_EDIT, EDITOR_ACCESS) sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_VIEW, VIEWER_ACCESS) }) + + apiKeyScenario("Given api key with viewer role", t, m.ROLE_VIEWER, func(sc *scenarioContext) { + // dashboard has default permissions + sc.defaultPermissionScenario(VIEWER, VIEWER_ACCESS) + }) }) } -func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, permission m.PermissionType, flag permissionFlags) { +func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, flag permissionFlags) { _, callerFile, callerLine, _ := runtime.Caller(1) sc.callerFile = callerFile sc.callerLine = callerLine @@ -267,7 +278,7 @@ func (sc *scenarioContext) verifyExpectedPermissionsFlags() { actualFlag = NO_ACCESS } - if sc.expectedFlags&actualFlag != sc.expectedFlags { + if actualFlag&sc.expectedFlags != actualFlag { sc.reportFailure(tc, sc.expectedFlags.String(), actualFlag.String()) } diff --git a/pkg/services/guardian/guardian_util_test.go b/pkg/services/guardian/guardian_util_test.go index b065c4194ad..3d839e71b74 100644 --- a/pkg/services/guardian/guardian_util_test.go +++ b/pkg/services/guardian/guardian_util_test.go @@ -48,6 +48,27 @@ func orgRoleScenario(desc string, t *testing.T, role m.RoleType, fn scenarioFunc }) } +func apiKeyScenario(desc string, t *testing.T, role m.RoleType, fn scenarioFunc) { + user := &m.SignedInUser{ + UserId: 0, + OrgId: orgID, + OrgRole: role, + ApiKeyId: 10, + } + guard := New(dashboardID, orgID, user) + sc := &scenarioContext{ + t: t, + orgRoleScenario: desc, + givenUser: user, + givenDashboardID: dashboardID, + g: guard, + } + + Convey(desc, func() { + fn(sc) + }) +} + func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) { bus.ClearBusHandlers() diff --git a/pkg/services/rendering/interface.go b/pkg/services/rendering/interface.go index 9498071f264..85c139cfc04 100644 --- a/pkg/services/rendering/interface.go +++ b/pkg/services/rendering/interface.go @@ -10,6 +10,7 @@ import ( var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter") var ErrNoRenderer = errors.New("No renderer plugin found nor is an external render server configured") +var ErrPhantomJSNotInstalled = errors.New("PhantomJS executable not found") type Opts struct { Width int diff --git a/pkg/services/rendering/phantomjs.go b/pkg/services/rendering/phantomjs.go index d4ceac0ed43..8e06b5fed9d 100644 --- a/pkg/services/rendering/phantomjs.go +++ b/pkg/services/rendering/phantomjs.go @@ -24,6 +24,11 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) ( url := rs.getURL(opts.Path) binPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, executable)) + if _, err := os.Stat(binPath); os.IsNotExist(err) { + rs.log.Error("executable not found", "executable", binPath) + return nil, ErrPhantomJSNotInstalled + } + scriptPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, "render.js")) pngPath := rs.getFilePathForNewImage() diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index 52da7a99516..a65bc136554 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -238,18 +238,19 @@ func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error { queryParams []interface{} ) + sqlog.Info("delete", "orgId", params.OrgId) if params.RegionId != 0 { - annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ?)" - sql = "DELETE FROM annotation WHERE region_id = ?" - queryParams = []interface{}{params.RegionId} + annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ? AND org_id = ?)" + sql = "DELETE FROM annotation WHERE region_id = ? AND org_id = ?" + queryParams = []interface{}{params.RegionId, params.OrgId} } else if params.Id != 0 { - annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ?)" - sql = "DELETE FROM annotation WHERE id = ?" - queryParams = []interface{}{params.Id} + annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ? AND org_id = ?)" + sql = "DELETE FROM annotation WHERE id = ? AND org_id = ?" + queryParams = []interface{}{params.Id, params.OrgId} } else { - annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ?)" - sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?" - queryParams = []interface{}{params.DashboardId, params.PanelId} + annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ? AND org_id = ?)" + sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ? AND org_id = ?" + queryParams = []interface{}{params.DashboardId, params.PanelId, params.OrgId} } if _, err := sess.Exec(annoTagSql, queryParams...); err != nil { diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index 01a95c7db7b..c0d267f2578 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -268,7 +268,7 @@ func TestAnnotations(t *testing.T) { annotationId := items[0].Id - err = repo.Delete(&annotations.DeleteParams{Id: annotationId}) + err = repo.Delete(&annotations.DeleteParams{Id: annotationId, OrgId: 1}) So(err, ShouldBeNil) items, err = repo.Find(query) diff --git a/pkg/services/sqlstore/session.go b/pkg/services/sqlstore/session.go index c85346231e4..29d7392678f 100644 --- a/pkg/services/sqlstore/session.go +++ b/pkg/services/sqlstore/session.go @@ -27,18 +27,18 @@ func startSession(ctx context.Context, engine *xorm.Engine, beginTran bool) (*DB var sess *DBSession sess, ok := value.(*DBSession) - if !ok { - newSess := &DBSession{Session: engine.NewSession()} - if beginTran { - err := newSess.Begin() - if err != nil { - return nil, err - } - } - return newSess, nil + if ok { + return sess, nil } - return sess, nil + newSess := &DBSession{Session: engine.NewSession()} + if beginTran { + err := newSess.Begin() + if err != nil { + return nil, err + } + } + return newSess, nil } func withDbSession(ctx context.Context, callback dbTransactionFunc) error { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 40101528df5..b0edc1676e0 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -26,7 +26,7 @@ import ( _ "github.com/grafana/grafana/pkg/tsdb/mssql" _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" + sqlite3 "github.com/mattn/go-sqlite3" ) var ( @@ -56,6 +56,64 @@ type SqlStore struct { skipEnsureAdmin bool } +// NewSession returns a new DBSession +func (ss *SqlStore) NewSession() *DBSession { + return &DBSession{Session: ss.engine.NewSession()} +} + +// WithDbSession calls the callback with an session attached to the context. +func (ss *SqlStore) WithDbSession(ctx context.Context, callback dbTransactionFunc) error { + sess, err := startSession(ctx, ss.engine, false) + if err != nil { + return err + } + + return callback(sess) +} + +// WithTransactionalDbSession calls the callback with an session within a transaction +func (ss *SqlStore) WithTransactionalDbSession(ctx context.Context, callback dbTransactionFunc) error { + return ss.inTransactionWithRetryCtx(ctx, callback, 0) +} + +func (ss *SqlStore) inTransactionWithRetryCtx(ctx context.Context, callback dbTransactionFunc, retry int) error { + sess, err := startSession(ctx, ss.engine, true) + if err != nil { + return err + } + + defer sess.Close() + + err = callback(sess) + + // special handling of database locked errors for sqlite, then we can retry 3 times + if sqlError, ok := err.(sqlite3.Error); ok && retry < 5 { + if sqlError.Code == sqlite3.ErrLocked { + sess.Rollback() + time.Sleep(time.Millisecond * time.Duration(10)) + sqlog.Info("Database table locked, sleeping then retrying", "retry", retry) + return ss.inTransactionWithRetryCtx(ctx, callback, retry+1) + } + } + + if err != nil { + sess.Rollback() + return err + } else if err = sess.Commit(); err != nil { + return err + } + + if len(sess.events) > 0 { + for _, e := range sess.events { + if err = bus.Publish(e); err != nil { + log.Error(3, "Failed to publish event after commit", err) + } + } + } + + return nil +} + func (ss *SqlStore) Init() error { ss.log = log.New("sqlstore") ss.readConfig() diff --git a/public/app/core/components/search/search_results.ts b/public/app/core/components/search/search_results.ts index 273af224660..35ee1365e22 100644 --- a/public/app/core/components/search/search_results.ts +++ b/public/app/core/components/search/search_results.ts @@ -63,7 +63,8 @@ export class SearchResultsCtrl { } onItemClick(item) { - if (this.$location.path().indexOf(item.url) > -1) { + //Check if one string can be found in the other + if (this.$location.path().indexOf(item.url) > -1 || item.url.indexOf(this.$location.path()) > -1) { appEvents.emit('hide-dash-search'); } } diff --git a/public/app/core/specs/ticks.jest.ts b/public/app/core/specs/ticks.jest.ts new file mode 100644 index 00000000000..8b7e0cd73b5 --- /dev/null +++ b/public/app/core/specs/ticks.jest.ts @@ -0,0 +1,25 @@ +import * as ticks from '../utils/ticks'; + +describe('ticks', () => { + describe('getFlotTickDecimals()', () => { + let ctx: any = {}; + + beforeEach(() => { + ctx.axis = {}; + }); + + it('should calculate decimals precision based on graph height', () => { + let dec = ticks.getFlotTickDecimals(0, 10, ctx.axis, 200); + expect(dec.tickDecimals).toBe(1); + expect(dec.scaledDecimals).toBe(1); + + dec = ticks.getFlotTickDecimals(0, 100, ctx.axis, 200); + expect(dec.tickDecimals).toBe(0); + expect(dec.scaledDecimals).toBe(-1); + + dec = ticks.getFlotTickDecimals(0, 1, ctx.axis, 200); + expect(dec.tickDecimals).toBe(2); + expect(dec.scaledDecimals).toBe(3); + }); + }); +}); diff --git a/public/app/core/specs/time_series.jest.ts b/public/app/core/specs/time_series.jest.ts index 6214c687add..f5245476218 100644 --- a/public/app/core/specs/time_series.jest.ts +++ b/public/app/core/specs/time_series.jest.ts @@ -1,4 +1,5 @@ import TimeSeries from 'app/core/time_series2'; +import { updateLegendValues } from 'app/core/time_series2'; describe('TimeSeries', function() { var points, series; @@ -311,4 +312,55 @@ describe('TimeSeries', function() { expect(series.formatValue(-Infinity)).toBe(''); }); }); + + describe('legend decimals', function() { + let series, panel; + let height = 200; + beforeEach(function() { + testData = { + alias: 'test', + datapoints: [[1, 2], [0, 3], [10, 4], [8, 5]], + }; + series = new TimeSeries(testData); + series.getFlotPairs(); + panel = { + decimals: null, + yaxes: [ + { + decimals: null, + }, + ], + }; + }); + + it('should set decimals based on Y axis (expect calculated decimals = 1)', function() { + let data = [series]; + // Expect ticks with this data will have decimals = 1 + updateLegendValues(data, panel, height); + expect(data[0].decimals).toBe(2); + }); + + it('should set decimals based on Y axis to 0 if calculated decimals = 0)', function() { + testData.datapoints = [[10, 2], [0, 3], [100, 4], [80, 5]]; + series = new TimeSeries(testData); + series.getFlotPairs(); + let data = [series]; + updateLegendValues(data, panel, height); + expect(data[0].decimals).toBe(0); + }); + + it('should set decimals to Y axis decimals + 1', function() { + panel.yaxes[0].decimals = 2; + let data = [series]; + updateLegendValues(data, panel, height); + expect(data[0].decimals).toBe(3); + }); + + it('should set decimals to legend decimals value if it was set explicitly', function() { + panel.decimals = 3; + let data = [series]; + updateLegendValues(data, panel, height); + expect(data[0].decimals).toBe(3); + }); + }); }); diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index 4da64850e59..59729ebc312 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -23,23 +23,27 @@ function translateFillOption(fill) { * Calculate decimals for legend and update values for each series. * @param data series data * @param panel + * @param height */ -export function updateLegendValues(data: TimeSeries[], panel) { +export function updateLegendValues(data: TimeSeries[], panel, height) { for (let i = 0; i < data.length; i++) { let series = data[i]; - let yaxes = panel.yaxes; + const yaxes = panel.yaxes; const seriesYAxis = series.yaxis || 1; - let axis = yaxes[seriesYAxis - 1]; - let { tickDecimals, scaledDecimals } = getFlotTickDecimals(data, axis); - let formater = kbn.valueFormats[panel.yaxes[seriesYAxis - 1].format]; + const axis = yaxes[seriesYAxis - 1]; + let formater = kbn.valueFormats[axis.format]; // decimal override if (_.isNumber(panel.decimals)) { series.updateLegendValues(formater, panel.decimals, null); + } else if (_.isNumber(axis.decimals)) { + series.updateLegendValues(formater, axis.decimals + 1, null); } else { // auto decimals // legend and tooltip gets one more decimal precision // than graph legend ticks + const { datamin, datamax } = getDataMinMax(data); + let { tickDecimals, scaledDecimals } = getFlotTickDecimals(datamin, datamax, axis, height); tickDecimals = (tickDecimals || -1) + 1; series.updateLegendValues(formater, tickDecimals, scaledDecimals + 2); } diff --git a/public/app/core/utils/ticks.ts b/public/app/core/utils/ticks.ts index db65104cfc0..66e6a7ce4fc 100644 --- a/public/app/core/utils/ticks.ts +++ b/public/app/core/utils/ticks.ts @@ -1,5 +1,3 @@ -import { getDataMinMax } from 'app/core/time_series2'; - /** * Calculate tick step. * Implementation from d3-array (ticks.js) @@ -121,12 +119,10 @@ export function getFlotRange(panelMin, panelMax, datamin, datamax) { * Calculate tick decimals. * Implementation from Flot. */ -export function getFlotTickDecimals(data, axis) { - let { datamin, datamax } = getDataMinMax(data); - let { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax); - let noTicks = 3; - let tickDecimals, maxDec; - let delta = (max - min) / noTicks; +export function getFlotTickDecimals(datamin, datamax, axis, height) { + const { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax); + const noTicks = 0.3 * Math.sqrt(height); + const delta = (max - min) / noTicks; let dec = -Math.floor(Math.log(delta) / Math.LN10); let magn = Math.pow(10, -dec); @@ -139,19 +135,17 @@ export function getFlotTickDecimals(data, axis) { } else if (norm < 3) { size = 2; // special case for 2.5, requires an extra decimal - if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { + if (norm > 2.25) { size = 2.5; - ++dec; } } else if (norm < 7.5) { size = 5; } else { size = 10; } - size *= magn; - tickDecimals = Math.max(0, maxDec != null ? maxDec : dec); + const tickDecimals = Math.max(0, -Math.floor(Math.log(delta) / Math.LN10) + 1); // grafana addition const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10); return { tickDecimals, scaledDecimals }; diff --git a/public/app/features/annotations/specs/annotations_srv_specs.ts b/public/app/features/annotations/specs/annotations_srv_specs.ts index c18638e3f12..932fcf9415c 100644 --- a/public/app/features/annotations/specs/annotations_srv_specs.ts +++ b/public/app/features/annotations/specs/annotations_srv_specs.ts @@ -1,15 +1,18 @@ import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common'; import '../annotations_srv'; import helpers from 'test/specs/helpers'; +import 'app/features/dashboard/time_srv'; describe('AnnotationsSrv', function() { var ctx = new helpers.ServiceTestContext(); beforeEach(angularMocks.module('grafana.core')); beforeEach(angularMocks.module('grafana.services')); + beforeEach(ctx.createService('timeSrv')); beforeEach(() => { ctx.createService('annotationsSrv'); }); + describe('When translating the query result', () => { const annotationSource = { datasource: '-- Grafana --', diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index a37e753bd89..976e4213920 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -22,10 +22,10 @@ export class DashboardModel { editable: any; graphTooltip: any; time: any; - originalTime: any; + private originalTime: any; timepicker: any; templating: any; - originalTemplating: any; + private originalTemplating: any; annotations: any; refresh: any; snapshot: any; @@ -50,6 +50,8 @@ export class DashboardModel { meta: true, panels: true, // needs special handling templating: true, // needs special handling + originalTime: true, + originalTemplating: true, }; constructor(data, meta?) { @@ -70,12 +72,8 @@ export class DashboardModel { this.editable = data.editable !== false; this.graphTooltip = data.graphTooltip || 0; this.time = data.time || { from: 'now-6h', to: 'now' }; - this.originalTime = _.cloneDeep(this.time); this.timepicker = data.timepicker || {}; this.templating = this.ensureListExist(data.templating); - this.originalTemplating = _.map(this.templating.list, variable => { - return { name: variable.name, current: _.clone(variable.current) }; - }); this.annotations = this.ensureListExist(data.annotations); this.refresh = data.refresh; this.snapshot = data.snapshot; @@ -85,6 +83,9 @@ export class DashboardModel { this.gnetId = data.gnetId || null; this.panels = _.map(data.panels || [], panelData => new PanelModel(panelData)); + this.resetOriginalVariables(); + this.resetOriginalTime(); + this.initMeta(meta); this.updateSchema(data); @@ -138,8 +139,8 @@ export class DashboardModel { // cleans meta data and other non persistent state getSaveModelClone(options?) { let defaults = _.defaults(options || {}, { - saveVariables: false, - saveTimerange: false, + saveVariables: true, + saveTimerange: true, }); // make clone @@ -153,15 +154,23 @@ export class DashboardModel { } // get variable save models - //console.log(this.templating.list); copy.templating = { list: _.map(this.templating.list, variable => (variable.getSaveModel ? variable.getSaveModel() : variable)), }; - if (!defaults.saveVariables && copy.templating.list.length === this.originalTemplating.length) { + if (!defaults.saveVariables) { for (let i = 0; i < copy.templating.list.length; i++) { - if (copy.templating.list[i].name === this.originalTemplating[i].name) { - copy.templating.list[i].current = this.originalTemplating[i].current; + let current = copy.templating.list[i]; + let original = _.find(this.originalTemplating, { name: current.name, type: current.type }); + + if (!original) { + continue; + } + + if (current.type === 'adhoc') { + copy.templating.list[i].filters = original.filters; + } else { + copy.templating.list[i].current = original.current; } } } @@ -785,4 +794,40 @@ export class DashboardModel { let migrator = new DashboardMigrator(this); migrator.updateSchema(old); } + + resetOriginalTime() { + this.originalTime = _.cloneDeep(this.time); + } + + hasTimeChanged() { + return !_.isEqual(this.time, this.originalTime); + } + + resetOriginalVariables() { + this.originalTemplating = _.map(this.templating.list, variable => { + return { + name: variable.name, + type: variable.type, + current: _.cloneDeep(variable.current), + filters: _.cloneDeep(variable.filters), + }; + }); + } + + hasVariableValuesChanged() { + if (this.templating.list.length !== this.originalTemplating.length) { + return false; + } + + const updated = _.map(this.templating.list, variable => { + return { + name: variable.name, + type: variable.type, + current: _.cloneDeep(variable.current), + filters: _.cloneDeep(variable.filters), + }; + }); + + return !_.isEqual(updated, this.originalTemplating); + } } diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/export/exporter.ts index 8b93c12bf50..fc24de76fcc 100644 --- a/public/app/features/dashboard/export/exporter.ts +++ b/public/app/features/dashboard/export/exporter.ts @@ -63,8 +63,7 @@ export class DashboardExporter { ); }; - // check up panel data sources - for (let panel of saveModel.panels) { + const processPanel = panel => { if (panel.datasource !== undefined) { templateizeDatasourceUsage(panel); } @@ -86,6 +85,18 @@ export class DashboardExporter { version: panelDef.info.version, }; } + }; + + // check up panel data sources + for (let panel of saveModel.panels) { + processPanel(panel); + + // handle collapsed rows + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (let rowPanel of panel.panels) { + processPanel(rowPanel); + } + } } // templatize template vars diff --git a/public/app/features/dashboard/history/history_srv.ts b/public/app/features/dashboard/history/history_srv.ts index bd6e7223a23..7f7dc950de3 100644 --- a/public/app/features/dashboard/history/history_srv.ts +++ b/public/app/features/dashboard/history/history_srv.ts @@ -32,11 +32,11 @@ export interface DiffTarget { export class HistorySrv { /** @ngInject */ - constructor(private backendSrv, private $q) {} + constructor(private backendSrv) {} getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) { const id = dashboard && dashboard.id ? dashboard.id : void 0; - return id ? this.backendSrv.get(`api/dashboards/id/${id}/versions`, options) : this.$q.when([]); + return id ? this.backendSrv.get(`api/dashboards/id/${id}/versions`, options) : Promise.resolve([]); } calculateDiff(options: CalculateDiffOptions) { @@ -46,7 +46,8 @@ export class HistorySrv { restoreDashboard(dashboard: DashboardModel, version: number) { const id = dashboard && dashboard.id ? dashboard.id : void 0; const url = `api/dashboards/id/${id}/restore`; - return id && _.isNumber(version) ? this.backendSrv.post(url, { version }) : this.$q.when({}); + + return id && _.isNumber(version) ? this.backendSrv.post(url, { version }) : Promise.resolve({}); } } diff --git a/public/app/features/dashboard/save_modal.ts b/public/app/features/dashboard/save_modal.ts index 525d2d1cbc3..3afcbab707c 100644 --- a/public/app/features/dashboard/save_modal.ts +++ b/public/app/features/dashboard/save_modal.ts @@ -1,5 +1,4 @@ import coreModule from 'app/core/core_module'; -import _ from 'lodash'; const template = `