diff --git a/.floo b/.floo deleted file mode 100644 index 1c2038f98cc..00000000000 --- a/.floo +++ /dev/null @@ -1,3 +0,0 @@ -{ - "url": "https://floobits.com/raintank/grafana" -} diff --git a/.flooignore b/.flooignore deleted file mode 100644 index 43cddf93bdf..00000000000 --- a/.flooignore +++ /dev/null @@ -1,12 +0,0 @@ -#* -*.o -*.pyc -*.pyo -*~ -extern/ -node_modules/ -tmp/ -data/ -vendor/ -public_gen/ -dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ba944182794..b17a86893ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,43 @@ # 5.0.0 (unreleased) +### WIP (in develop branch currently as its unstable or unfinished) +- Dashboard folders +- User groups +- Dashboard permissions (on folder & dashboard level), permissions can be assigned to groups or individual users +- UX changes to nav & side menu +- New dashboard grid layout system + +# 4.5.0 (unreleased) + +# 4.5.0-beta1 (2017-09-05) + ## New Features -* **Table panel**: Render cell values as links that can use url that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +* **Table panel**: Render cell values as links that can have an url template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +* **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052). +* **MySQL**: New rich query editor with syntax highlighting +* **Prometheus**: New rich query editor with syntax highlighting, metric & range auto complete and integrated function docs. [#5117](https://github.com/grafana/grafana/issues/5117) ## Enhancements * **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) * **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboad time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) * **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) - +- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). + +### Breaking change + +* **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. + ## Changes * **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) - -# 4.4.4 (unreleased) +* **InfluxDB**: Added paranthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) ## Bug Fixes +* **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800) +* **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510) * **MySQL/Postgres**: Fix for max_idle_conn option default which was wrongly set to zero which does not mean unlimited but means zero, which in practice kind of disables connection pooling, which is not good. Fixes [#8513](https://github.com/grafana/grafana/issues/8513) # 4.4.3 (2017-08-07) diff --git a/README.md b/README.md index 41f777d5dec..5de6a7122ab 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) +[Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) [![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana) ================ [Website](https://grafana.com) | [Twitter](https://twitter.com/grafana) | @@ -124,7 +124,7 @@ To build the frontend assets only on changes: ```bash sudo npm install -g grunt-cli # to do only once to install grunt command line interface -grunt watch +grunt && grunt watch ``` ### Recompile backend on source change diff --git a/ROADMAP.md b/ROADMAP.md index 260c4151442..62e6719c4f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,31 +1,29 @@ -# Roadmap (2017-04-23) +# Roadmap (2017-08-29) 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-4 months) - - New Heatmap Panel (Implemented and available in master) - - Support for MySQL & Postgres as data sources (Work started and a alpha version for MySQL is available in master) - - User Groups & Dashboard folders with ACLs (work started, not yet completed, https://github.com/grafana/grafana/issues/1611#issuecomment-287742633) - - Improve new user UX - - Improve docs - - Support for alerting for Elasticsearch (can be tested in [branch](https://github.com/grafana/grafana/tree/alerting-elasticsearch) but needs more work) - - Graph annotations (create from grafana, region annotations, better annotation viz) - - Improve alerting (clustering, silence rules) + - Release Grafana v4.5 with fixes and minor enhancements + - Release Grafana v5 + - User groups + - Dashboard folders + - Dashboard permissions (on folders as well), permissions on groups or users + - New Dashboard layout engine + - New sidemenu & nav UX + - Elasticsearch alerting ### Long term -- Improved dashboard panel layout engine (to make it easier and enable more flexible layouts) - Backend plugins to support more Auth options, Alerting data sources & notifications - Universial time series transformations for any data source (meta queries) - Reporting - Web socket & live data streams -- Migrate to Angular2 +- Migrate to Angular2 or react ### Outside contributions We know this is being worked on right now by contributors (and we hope to merge it when it's ready). -- Dashboard revisions (be able to revert dashboard changes) - Clustering for alert engine (load distribution) diff --git a/docs/VERSION b/docs/VERSION index 2fa2c5705ad..b570a0ac21b 100644 --- a/docs/VERSION +++ b/docs/VERSION @@ -1 +1 @@ -v4.2 +v4.3 diff --git a/docs/sources/archive.md b/docs/sources/archive.md index 8ed38b933e6..68cd6f9c321 100644 --- a/docs/sources/archive.md +++ b/docs/sources/archive.md @@ -13,6 +13,7 @@ Here you can find links to older versions of the documentation that might be bet of Grafana. - [Latest](http://docs.grafana.org) +- [Version 4.3](http://docs.grafana.org/v4.3) - [Version 4.2](http://docs.grafana.org/v4.2) - [Version 4.1](http://docs.grafana.org/v4.1) - [Version 4.0](http://docs.grafana.org/v4.0) diff --git a/docs/sources/guides/getting_started.md b/docs/sources/guides/getting_started.md index f4c9bb9647f..871aa6c1e1b 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -24,7 +24,7 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course ### Top header -Let's start with creating a new Dashboard. You can find the new Dashboard link at the bottom of the Dashboard picker. You now have a blank Dashboard. +Let's start with creating a new Dashboard. You can find the new Dashboard link at the bottom of the Dashboard picker. You now have a blank Dashboard. @@ -44,7 +44,7 @@ Dashboards are at the core of what Grafana is all about. Dashboards are composed ## Adding & Editing Graphs and Panels -![](/img/docs/v2/graph_metrics_tab_graphite.png) +![](/img/docs/v45/metrics_tab.png) 1. You add panels via row menu. The row menu is the green icon to the left of each row. 2. To edit the graph you click on the graph title to open the panel menu, then `Edit`. diff --git a/docs/sources/guides/whats-new-in-v4-5.md b/docs/sources/guides/whats-new-in-v4-5.md new file mode 100644 index 00000000000..7e12b6f14b2 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v4-5.md @@ -0,0 +1,69 @@ ++++ +title = "What's New in Grafana v4.5" +description = "Feature & improvement highlights for Grafana v4.5" +keywords = ["grafana", "new", "documentation", "4.5"] +type = "docs" +[menu.docs] +name = "Version 4.5" +identifier = "v4.5" +parent = "whatsnew" +weight = -4 ++++ + +# What's New in Grafana v4.5 + +## Hightlights + +### New prometheus query editor + +The new query editor has full syntax highlighting. As well as auto complete for metrics, functions, and range vectors. + +![](/img/docs/v45/new_prom_editor_1.png) + +There is also integrated function docs right from the query editor! + +![](/img/docs/v45/new_prom_editor_2.png) + +### Elasticsearch: Add ad-hoc filters from the table panel +![](/img/docs/v45/elastic_ad_hoc_filters.png) + +### Table cell links! +Create column styles that turn cells into links that use the value in the cell (or other other row values) to generate a url to another dashboard or system: +![](/img/docs/v45/table_links.jpg) + +## Changelog + +### New Features + +* **Table panel**: Render cell values as links that can have an url template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +* **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052). +* **MySQL**: New rich query editor with syntax highlighting +* **Prometheus**: New rich query editor with syntax highlighting, metric & range auto complete and integrated function docs. [#5117](https://github.com/grafana/grafana/issues/5117) + +### Enhancements + +* **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) +* **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboad time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) +* **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) +- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). + +### Breaking change + +* **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. + +This option is now rennamed (and moved to Options sub section above your queries): +![image|519x120](upload://ySjHOVpavV6yk9LHQxL9nq2HIsT.png) + +Datas source selection & options & help are now above your metric queries. +![image|690x179](upload://5kNDxKgMz1BycOKgG3iWYLsEVXv.png) + +### Minor Changes + +* **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) +* **InfluxDB**: Added paranthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) + +## Bug Fixes + +* **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800) +* **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510) + diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 1efe073e94b..d7e5d3f8b7c 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -11,14 +11,16 @@ parent = "http_api" # Admin API -The admin http API does not currently work with an api token. Api Token's are currently only linked to an organization and organization role. They cannot given -the permission of server admin, only user's can be given that permission. So in order to use these API calls you will have to use basic auth and Grafana user -with Grafana admin permission. +The Admin HTTP API does not currently work with an API Token. API Tokens are currently only linked to an organization and an organization role. They cannot be given +the permission of server admin, only users can be given that permission. So in order to use these API calls you will have to use Basic Auth and the Grafana user +must have the Grafana Admin permission. (The default admin user is called `admin` and has permission to use this API.) ## Settings `GET /api/admin/settings` +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + **Example Request**: GET /api/admin/settings @@ -176,6 +178,8 @@ with Grafana admin permission. `GET /api/admin/stats` +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + **Example Request**: GET /api/admin/stats @@ -203,7 +207,7 @@ with Grafana admin permission. `POST /api/admin/users` -Create new user +Create new user. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. **Example Request**: @@ -229,7 +233,8 @@ Create new user `PUT /api/admin/users/:id/password` -Change password for specific user +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +Change password for a specific user. **Example Request**: @@ -250,6 +255,8 @@ Change password for specific user `PUT /api/admin/users/:id/permissions` +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + **Example Request**: PUT /api/admin/users/2/permissions HTTP/1.1 @@ -269,6 +276,8 @@ Change password for specific user `DELETE /api/admin/users/:id` +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + **Example Request**: DELETE /api/admin/users/2 HTTP/1.1 @@ -286,6 +295,8 @@ Change password for specific user `POST /api/admin/pause-all-alerts` +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + **Example Request**: POST /api/admin/pause-all-alerts HTTP/1.1 diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 2eaa3018639..ae2541b4a7f 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -15,6 +15,12 @@ weight = 1 The Grafana back-end has a number of configuration options that can be specified in a `.ini` configuration file or specified using environment variables. +## Comments In .ini Files + +Semicolons (the `;` char) are the standard way to comment out lines in a `.ini` file. + +A common problem is forgetting to uncomment a line in the `custom.ini` (or `grafana.ini`) file which causes the configuration option to be ignored. + ## Config file locations - Default configuration from `$WORKING_DIR/conf/defaults.ini` diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 193fe4d596c..a65e2efe339 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,6 +16,7 @@ weight = 1 Description | Download ------------ | ------------- Stable for Debian-based Linux | [grafana_4.4.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.4.3_amd64.deb) +Beta for Debian-based Linux | [grafana_4.5.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.0-beta1_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -28,15 +29,13 @@ sudo apt-get install -y adduser libfontconfig sudo dpkg -i grafana_4.4.3_amd64.deb ``` - ## APT Repository diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index d9281e84779..76c380e6750 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -16,6 +16,7 @@ weight = 2 Description | Download ------------ | ------------- Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.4.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.3-1.x86_64.rpm) +Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [4.5.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.0-beta1.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 341a0ac92fb..36308adf52f 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -88,7 +88,7 @@ The query expressions are different for each data source. - [Elasticsearch templating queries]({{< relref "features/datasources/elasticsearch.md#templating" >}}) - [InfluxDB templating queries]({{< relref "features/datasources/influxdb.md#templating" >}}) - [Prometheus templating queries]({{< relref "features/datasources/prometheus.md#templating" >}}) -- [OpenTSDB templating queries]({{< relref "features/datasources/prometheus.md#templating" >}}) +- [OpenTSDB templating queries]({{< relref "features/datasources/opentsdb.md#templating" >}}) One thing to note is that query expressions can contain references to other variables and in effect create linked variables. Grafana will detect this and automatically refresh a variable when one of it's containing variables change. @@ -97,7 +97,7 @@ Grafana will detect this and automatically refresh a variable when one of it's c Option | Description ------- | -------- -*Mulit-value* | If enabled, the variable will support the selection of multiple options at the same time. +*Multi-value* | If enabled, the variable will support the selection of multiple options at the same time. *Include All option* | Add a special `All` option whose value includes all options. *Custom all value* | By default the `All` value will include all options in combined expression. This can become very long and can have performance problems. Many times it can be better to specify a custom all value, like a wildcard regex. To make it possible to have custom regex, globs or lucene syntax in the **Custom all value** option it is never escaped so you will have to think avbout what is a valid value for your data source. diff --git a/package.json b/package.json index 323aa5d70b2..d19767921e9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "author": { "name": "Torkel Ödegaard", - "company": "Coding Instinct AB" + "company": "Grafana Labs" }, "name": "grafana", - "version": "5.0.0-pre1", + "version": "4.5.0-beta1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -63,6 +63,7 @@ }, "license": "Apache-2.0", "dependencies": { + "ace-builds": "^1.2.8", "eventemitter3": "^2.0.2", "gaze": "^1.1.2", "gridstack": "https://github.com/grafana/gridstack.js#grafana", diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index 68e1a03f3f0..8e25a0e4124 100755 --- a/packaging/deb/control/postinst +++ b/packaging/deb/control/postinst @@ -6,6 +6,7 @@ set -e IS_UPGRADE=false + case "$1" in configure) [ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana" diff --git a/packaging/deb/default/grafana-server b/packaging/deb/default/grafana-server index cc5ee866f7d..eaa75830d44 100644 --- a/packaging/deb/default/grafana-server +++ b/packaging/deb/default/grafana-server @@ -17,3 +17,6 @@ CONF_FILE=/etc/grafana/grafana.ini RESTART_ON_UPGRADE=true PLUGINS_DIR=/var/lib/grafana/plugins + +# Only used on systemd systems +PID_FILE_DIR=/var/run/grafana diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index a2eb6f1880a..cb7b87932d1 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/packaging/deb/systemd/grafana-server.service @@ -12,11 +12,13 @@ Group=grafana Type=simple Restart=on-failure WorkingDirectory=/usr/share/grafana -ExecStart=/usr/sbin/grafana-server \ - --config=${CONF_FILE} \ - --pidfile=${PID_FILE} \ - cfg:default.paths.logs=${LOG_DIR} \ - cfg:default.paths.data=${DATA_DIR} \ +RuntimeDirectory=grafana +RuntimeDirectoryMode=0750 +ExecStart=/usr/sbin/grafana-server \ + --config=${CONF_FILE} \ + --pidfile=${PID_FILE_DIR}/grafana-server.pid \ + cfg:default.paths.logs=${LOG_DIR} \ + cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} LimitNOFILE=10000 TimeoutStopSec=20 diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 0bf62a83040..17fabc5e136 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,10 +1,10 @@ #! /usr/bin/env bash -deb_ver=4.3.0-beta1 -rpm_ver=4.3.0-beta1 +deb_ver=4.5.0-beta1 +rpm_ver=4.5.0-beta1 -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb +# wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb -package_cloud push grafana/testing/debian/jessie grafana_${deb_ver}_amd64.deb +# package_cloud push grafana/testing/debian/jessie grafana_${deb_ver}_amd64.deb package_cloud push grafana/testing/debian/wheezy grafana_${deb_ver}_amd64.deb package_cloud push grafana/testing/debian/stretch grafana_${deb_ver}_amd64.deb diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index cf31112679d..0bfca949e7f 100755 --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -25,6 +25,7 @@ stopGrafana() { fi } + # Initial installation: $1 == 1 # Upgrade: $1 == 2, and configured to restart on upgrade if [ $1 -eq 1 ] ; then diff --git a/packaging/rpm/sysconfig/grafana-server b/packaging/rpm/sysconfig/grafana-server index cc5ee866f7d..eaa75830d44 100644 --- a/packaging/rpm/sysconfig/grafana-server +++ b/packaging/rpm/sysconfig/grafana-server @@ -17,3 +17,6 @@ CONF_FILE=/etc/grafana/grafana.ini RESTART_ON_UPGRADE=true PLUGINS_DIR=/var/lib/grafana/plugins + +# Only used on systemd systems +PID_FILE_DIR=/var/run/grafana diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index a6e02073d46..3e018e8b176 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/packaging/rpm/systemd/grafana-server.service @@ -12,11 +12,13 @@ Group=grafana Type=simple Restart=on-failure WorkingDirectory=/usr/share/grafana -ExecStart=/usr/sbin/grafana-server \ - --config=${CONF_FILE} \ - --pidfile=${PID_FILE} \ - cfg:default.paths.logs=${LOG_DIR} \ - cfg:default.paths.data=${DATA_DIR} \ +RuntimeDirectory=grafana +RuntimeDirectoryMode=0750 +ExecStart=/usr/sbin/grafana-server \ + --config=${CONF_FILE} \ + --pidfile=${PID_FILE_DIR}/grafana-server.pid \ + cfg:default.paths.logs=${LOG_DIR} \ + cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} LimitNOFILE=10000 TimeoutStopSec=20 diff --git a/pkg/api/api.go b/pkg/api/api.go index f16f85e7c8f..5f0b6f7144b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -222,7 +222,7 @@ func (hs *HttpServer) registerRoutes() { r.Get("/plugins", wrap(GetPluginList)) r.Get("/plugins/:pluginId/settings", wrap(GetPluginSettingById)) - r.Get("/plugins/:pluginId/readme", wrap(GetPluginReadme)) + r.Get("/plugins/:pluginId/markdown/:name", wrap(GetPluginMarkdown)) r.Group("/plugins", func() { r.Get("/:pluginId/dashboards/", wrap(GetPluginDashboards)) @@ -230,8 +230,8 @@ func (hs *HttpServer) registerRoutes() { }, reqOrgAdmin) r.Get("/frontend/settings/", GetFrontendSettings) - r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest) - r.Any("/datasources/proxy/:id", reqSignedIn, ProxyDataSourceRequest) + r.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) + r.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) // Dashboard r.Group("/dashboards", func() { diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index aee67ac8478..812ea667eee 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -32,8 +32,7 @@ func InitAppPluginRoutes(r *macaron.Macaron) { url := util.JoinUrlFragments("/api/plugin-proxy/"+plugin.Id, route.Path) handlers := make([]macaron.Handler, 0) handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ - ReqSignedIn: true, - ReqGrafanaAdmin: route.ReqGrafanaAdmin, + ReqSignedIn: true, })) if route.ReqRole != "" { diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 1b862a9bdae..80280fd3cc9 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -217,7 +217,10 @@ func (this *thunderTask) Fetch() { this.Done() } -var client = &http.Client{} +var client *http.Client = &http.Client{ + Timeout: time.Second * 2, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +} func (this *thunderTask) fetch() error { this.Avatar.timestamp = time.Now() diff --git a/pkg/api/cloudwatch/metrics.go b/pkg/api/cloudwatch/metrics.go index 2daa93bfb03..e63798c0d8f 100644 --- a/pkg/api/cloudwatch/metrics.go +++ b/pkg/api/cloudwatch/metrics.go @@ -91,7 +91,7 @@ func init() { "AWS/SWF": {"DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "StartedDecisionTasksTimedOutOnClose", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut", "ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat"}, "AWS/VPN": {"TunnelState", "TunnelDataIn", "TunnelDataOut"}, - "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests"}, + "WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests"}, "AWS/WorkSpaces": {"Available", "Unhealthy", "ConnectionAttempt", "ConnectionSuccess", "ConnectionFailure", "SessionLaunchTime", "InSessionLatency", "SessionDisconnect"}, "KMS": {"SecondsUntilKeyMaterialExpiration"}, } @@ -133,7 +133,7 @@ func init() { "AWS/StorageGateway": {"GatewayId", "GatewayName", "VolumeId"}, "AWS/SWF": {"Domain", "WorkflowTypeName", "WorkflowTypeVersion", "ActivityTypeName", "ActivityTypeVersion"}, "AWS/VPN": {"VpnId", "TunnelIpAddress"}, - "AWS/WAF": {"Rule", "WebACL"}, + "WAF": {"Rule", "WebACL"}, "AWS/WorkSpaces": {"DirectoryId", "WorkspaceId"}, "KMS": {"KeyId"}, } @@ -166,9 +166,7 @@ func handleGetNamespaces(req *cwRequest, c *middleware.Context) { customNamespaces := req.DataSource.JsonData.Get("customMetricsNamespaces").MustString() if customNamespaces != "" { - for _, key := range strings.Split(customNamespaces, ",") { - keys = append(keys, key) - } + keys = append(keys, strings.Split(customNamespaces, ",")...) } sort.Sort(sort.StringSlice(keys)) @@ -292,11 +290,6 @@ func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error) var metricsCacheLock sync.Mutex func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*datasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { - result, err := getAllMetrics(dsInfo) - if err != nil { - return []string{}, err - } - metricsCacheLock.Lock() defer metricsCacheLock.Unlock() @@ -314,6 +307,10 @@ func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*data if customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire.After(time.Now()) { return customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache, nil } + result, err := getAllMetrics(dsInfo) + if err != nil { + return []string{}, err + } customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache = make([]string, 0) customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire = time.Now().Add(5 * time.Minute) @@ -330,11 +327,6 @@ func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*data var dimensionsCacheLock sync.Mutex func getDimensionsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*datasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { - result, err := getAllMetrics(dsInfo) - if err != nil { - return []string{}, err - } - dimensionsCacheLock.Lock() defer dimensionsCacheLock.Unlock() @@ -352,6 +344,10 @@ func getDimensionsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*d if customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire.After(time.Now()) { return customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache, nil } + result, err := getAllMetrics(dsInfo) + if err != nil { + return []string{}, err + } customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache = make([]string, 0) customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire = time.Now().Add(5 * time.Minute) diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 965653c2501..8a712f99804 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -1,197 +1,60 @@ package api import ( - "bytes" - "io/ioutil" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strings" + "fmt" "time" - "github.com/grafana/grafana/pkg/api/cloudwatch" + "github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/plugins" ) -var ( - dataproxyLogger log.Logger = log.New("data-proxy-log") -) +const HeaderNameNoBackendCache = "X-Grafana-NoCache" -func NewReverseProxy(ds *m.DataSource, proxyPath string, targetUrl *url.URL) *httputil.ReverseProxy { - director := func(req *http.Request) { - req.URL.Scheme = targetUrl.Scheme - req.URL.Host = targetUrl.Host - req.Host = targetUrl.Host +func (hs *HttpServer) getDatasourceById(id int64, orgId int64, nocache bool) (*m.DataSource, error) { + cacheKey := fmt.Sprintf("ds-%d", id) - reqQueryVals := req.URL.Query() - - if ds.Type == m.DS_INFLUXDB_08 { - req.URL.Path = util.JoinUrlFragments(targetUrl.Path, "db/"+ds.Database+"/"+proxyPath) - reqQueryVals.Add("u", ds.User) - reqQueryVals.Add("p", ds.Password) - req.URL.RawQuery = reqQueryVals.Encode() - } else if ds.Type == m.DS_INFLUXDB { - req.URL.Path = util.JoinUrlFragments(targetUrl.Path, proxyPath) - req.URL.RawQuery = reqQueryVals.Encode() - if !ds.BasicAuth { - req.Header.Del("Authorization") - req.Header.Add("Authorization", util.GetBasicAuthHeader(ds.User, ds.Password)) - } - } else { - req.URL.Path = util.JoinUrlFragments(targetUrl.Path, proxyPath) - } - - if ds.BasicAuth { - req.Header.Del("Authorization") - req.Header.Add("Authorization", util.GetBasicAuthHeader(ds.BasicAuthUser, ds.BasicAuthPassword)) - } - - dsAuth := req.Header.Get("X-DS-Authorization") - if len(dsAuth) > 0 { - req.Header.Del("X-DS-Authorization") - req.Header.Del("Authorization") - req.Header.Add("Authorization", dsAuth) - } - - // clear cookie headers - req.Header.Del("Cookie") - req.Header.Del("Set-Cookie") - - // clear X-Forwarded Host/Port/Proto headers - req.Header.Del("X-Forwarded-Host") - req.Header.Del("X-Forwarded-Port") - req.Header.Del("X-Forwarded-Proto") - - // set X-Forwarded-For header - if req.RemoteAddr != "" { - remoteAddr, _, err := net.SplitHostPort(req.RemoteAddr) - if err != nil { - remoteAddr = req.RemoteAddr - } - if req.Header.Get("X-Forwarded-For") != "" { - req.Header.Set("X-Forwarded-For", req.Header.Get("X-Forwarded-For")+", "+remoteAddr) - } else { - req.Header.Set("X-Forwarded-For", remoteAddr) + if !nocache { + if cached, found := hs.cache.Get(cacheKey); found { + ds := cached.(*m.DataSource) + if ds.OrgId == orgId { + return ds, nil } } - - // reqBytes, _ := httputil.DumpRequestOut(req, true); - // log.Trace("Proxying datasource request: %s", string(reqBytes)) } - return &httputil.ReverseProxy{Director: director, FlushInterval: time.Millisecond * 200} -} - -func getDatasource(id int64, orgId int64) (*m.DataSource, error) { query := m.GetDataSourceByIdQuery{Id: id, OrgId: orgId} if err := bus.Dispatch(&query); err != nil { return nil, err } + hs.cache.Set(cacheKey, query.Result, time.Second*5) return query.Result, nil } -func ProxyDataSourceRequest(c *middleware.Context) { +func (hs *HttpServer) ProxyDataSourceRequest(c *middleware.Context) { c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer) - ds, err := getDatasource(c.ParamsInt64(":id"), c.OrgId) + nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" + + ds, err := hs.getDatasourceById(c.ParamsInt64(":id"), c.OrgId, nocache) if err != nil { c.JsonApiErr(500, "Unable to load datasource meta data", err) return } - if ds.Type == m.DS_INFLUXDB { - if c.Query("db") != ds.Database { - c.JsonApiErr(403, "Datasource is not configured to allow this database", nil) - return - } - } - - if ds.Type == m.DS_CLOUDWATCH { - cloudwatch.HandleRequest(c, ds) - return - } - - targetUrl, _ := url.Parse(ds.Url) - if !checkWhiteList(c, targetUrl.Host) { + // find plugin + plugin, ok := plugins.DataSources[ds.Type] + if !ok { + c.JsonApiErr(500, "Unable to find datasource plugin", err) return } proxyPath := c.Params("*") - - if ds.Type == m.DS_PROMETHEUS { - if c.Req.Request.Method != http.MethodGet || !strings.HasPrefix(proxyPath, "api/") { - c.JsonApiErr(403, "GET is only allowed on proxied Prometheus datasource", nil) - return - } - } - - if ds.Type == m.DS_ES { - if c.Req.Request.Method == "DELETE" { - c.JsonApiErr(403, "Deletes not allowed on proxied Elasticsearch datasource", nil) - return - } - if c.Req.Request.Method == "PUT" { - c.JsonApiErr(403, "Puts not allowed on proxied Elasticsearch datasource", nil) - return - } - if c.Req.Request.Method == "POST" && proxyPath != "_msearch" { - c.JsonApiErr(403, "Posts not allowed on proxied Elasticsearch datasource except on /_msearch", nil) - return - } - } - - proxy := NewReverseProxy(ds, proxyPath, targetUrl) - proxy.Transport, err = ds.GetHttpTransport() - if err != nil { - c.JsonApiErr(400, "Unable to load TLS certificate", err) - return - } - - logProxyRequest(ds.Type, c) - proxy.ServeHTTP(c.Resp, c.Req.Request) - c.Resp.Header().Del("Set-Cookie") -} - -func logProxyRequest(dataSourceType string, c *middleware.Context) { - if !setting.DataProxyLogging { - return - } - - var body string - if c.Req.Request.Body != nil { - buffer, err := ioutil.ReadAll(c.Req.Request.Body) - if err == nil { - c.Req.Request.Body = ioutil.NopCloser(bytes.NewBuffer(buffer)) - body = string(buffer) - } - } - - dataproxyLogger.Info("Proxying incoming request", - "userid", c.UserId, - "orgid", c.OrgId, - "username", c.Login, - "datasource", dataSourceType, - "uri", c.Req.RequestURI, - "method", c.Req.Request.Method, - "body", body) -} - -func checkWhiteList(c *middleware.Context, host string) bool { - if host != "" && len(setting.DataProxyWhiteList) > 0 { - if _, exists := setting.DataProxyWhiteList[host]; !exists { - c.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil) - return false - } - } - - return true + proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath) + proxy.HandleRequest() } diff --git a/pkg/api/dataproxy_test.go b/pkg/api/dataproxy_test.go deleted file mode 100644 index f3ed6994cff..00000000000 --- a/pkg/api/dataproxy_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package api - -import ( - "net/http" - "net/url" - "testing" - - . "github.com/smartystreets/goconvey/convey" - - m "github.com/grafana/grafana/pkg/models" -) - -func TestDataSourceProxy(t *testing.T) { - Convey("When getting graphite datasource proxy", t, func() { - ds := m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE} - targetUrl, err := url.Parse(ds.Url) - proxy := NewReverseProxy(&ds, "/render", targetUrl) - proxy.Transport, err = ds.GetHttpTransport() - So(err, ShouldBeNil) - - transport, ok := proxy.Transport.(*http.Transport) - So(ok, ShouldBeTrue) - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldBeTrue) - - requestUrl, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestUrl} - - proxy.Director(&req) - - Convey("Can translate request url and path", func() { - So(req.URL.Host, ShouldEqual, "graphite:8080") - So(req.URL.Path, ShouldEqual, "/render") - }) - }) - - Convey("When getting influxdb datasource proxy", t, func() { - ds := m.DataSource{ - Type: m.DS_INFLUXDB_08, - Url: "http://influxdb:8083", - Database: "site", - User: "user", - Password: "password", - } - - targetUrl, _ := url.Parse(ds.Url) - proxy := NewReverseProxy(&ds, "", targetUrl) - - requestUrl, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestUrl} - - proxy.Director(&req) - - Convey("Should add db to url", func() { - So(req.URL.Path, ShouldEqual, "/db/site/") - }) - - Convey("Should add username and password", func() { - queryVals := req.URL.Query() - So(queryVals["u"][0], ShouldEqual, "user") - So(queryVals["p"][0], ShouldEqual, "password") - }) - }) -} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 4873062a933..54f08197bae 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -9,7 +9,9 @@ import ( "net/http" "os" "path" + "time" + gocache "github.com/patrickmn/go-cache" macaron "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/api/live" @@ -29,13 +31,15 @@ type HttpServer struct { macaron *macaron.Macaron context context.Context streamManager *live.StreamManager + cache *gocache.Cache httpSrv *http.Server } func NewHttpServer() *HttpServer { return &HttpServer{ - log: log.New("http.server"), + log: log.New("http.server"), + cache: gocache.New(5*time.Minute, 10*time.Minute), } } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go new file mode 100644 index 00000000000..fff8d099d4b --- /dev/null +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -0,0 +1,349 @@ +package pluginproxy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "text/template" + "time" + + "github.com/grafana/grafana/pkg/api/cloudwatch" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +var ( + logger log.Logger = log.New("data-proxy-log") + client *http.Client = &http.Client{ + Timeout: time.Second * 30, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + } + tokenCache = map[int64]*jwtToken{} +) + +type jwtToken struct { + ExpiresOn time.Time `json:"-"` + ExpiresOnString string `json:"expires_on"` + AccessToken string `json:"access_token"` +} + +type DataSourceProxy struct { + ds *m.DataSource + ctx *middleware.Context + targetUrl *url.URL + proxyPath string + route *plugins.AppPluginRoute + plugin *plugins.DataSourcePlugin +} + +func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *middleware.Context, proxyPath string) *DataSourceProxy { + targetUrl, _ := url.Parse(ds.Url) + + return &DataSourceProxy{ + ds: ds, + plugin: plugin, + ctx: ctx, + proxyPath: proxyPath, + targetUrl: targetUrl, + } +} + +func (proxy *DataSourceProxy) HandleRequest() { + if proxy.ds.Type == m.DS_CLOUDWATCH { + cloudwatch.HandleRequest(proxy.ctx, proxy.ds) + return + } + + if err := proxy.validateRequest(); err != nil { + proxy.ctx.JsonApiErr(403, err.Error(), nil) + return + } + + reverseProxy := &httputil.ReverseProxy{ + Director: proxy.getDirector(), + FlushInterval: time.Millisecond * 200, + } + + var err error + reverseProxy.Transport, err = proxy.ds.GetHttpTransport() + if err != nil { + proxy.ctx.JsonApiErr(400, "Unable to load TLS certificate", err) + return + } + + proxy.logRequest() + + reverseProxy.ServeHTTP(proxy.ctx.Resp, proxy.ctx.Req.Request) + proxy.ctx.Resp.Header().Del("Set-Cookie") +} + +func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { + return func(req *http.Request) { + req.URL.Scheme = proxy.targetUrl.Scheme + req.URL.Host = proxy.targetUrl.Host + req.Host = proxy.targetUrl.Host + + reqQueryVals := req.URL.Query() + + if proxy.ds.Type == m.DS_INFLUXDB_08 { + req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, "db/"+proxy.ds.Database+"/"+proxy.proxyPath) + reqQueryVals.Add("u", proxy.ds.User) + reqQueryVals.Add("p", proxy.ds.Password) + req.URL.RawQuery = reqQueryVals.Encode() + } else if proxy.ds.Type == m.DS_INFLUXDB { + req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath) + req.URL.RawQuery = reqQueryVals.Encode() + if !proxy.ds.BasicAuth { + req.Header.Del("Authorization") + req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.User, proxy.ds.Password)) + } + } else { + req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath) + } + + if proxy.ds.BasicAuth { + req.Header.Del("Authorization") + req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.BasicAuthUser, proxy.ds.BasicAuthPassword)) + } + + dsAuth := req.Header.Get("X-DS-Authorization") + if len(dsAuth) > 0 { + req.Header.Del("X-DS-Authorization") + req.Header.Del("Authorization") + req.Header.Add("Authorization", dsAuth) + } + + // clear cookie headers + req.Header.Del("Cookie") + req.Header.Del("Set-Cookie") + + // clear X-Forwarded Host/Port/Proto headers + req.Header.Del("X-Forwarded-Host") + req.Header.Del("X-Forwarded-Port") + req.Header.Del("X-Forwarded-Proto") + + // set X-Forwarded-For header + if req.RemoteAddr != "" { + remoteAddr, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + remoteAddr = req.RemoteAddr + } + if req.Header.Get("X-Forwarded-For") != "" { + req.Header.Set("X-Forwarded-For", req.Header.Get("X-Forwarded-For")+", "+remoteAddr) + } else { + req.Header.Set("X-Forwarded-For", remoteAddr) + } + } + + if proxy.route != nil { + proxy.applyRoute(req) + } + } +} + +func (proxy *DataSourceProxy) validateRequest() error { + if proxy.ds.Type == m.DS_INFLUXDB { + if proxy.ctx.Query("db") != proxy.ds.Database { + return errors.New("Datasource is not configured to allow this database") + } + } + + if !checkWhiteList(proxy.ctx, proxy.targetUrl.Host) { + return errors.New("Target url is not a valid target") + } + + if proxy.ds.Type == m.DS_PROMETHEUS { + if proxy.ctx.Req.Request.Method != http.MethodGet || !strings.HasPrefix(proxy.proxyPath, "api/") { + return errors.New("GET is only allowed on proxied Prometheus datasource") + } + } + + if proxy.ds.Type == m.DS_ES { + if proxy.ctx.Req.Request.Method == "DELETE" { + return errors.New("Deletes not allowed on proxied Elasticsearch datasource") + } + if proxy.ctx.Req.Request.Method == "PUT" { + return errors.New("Puts not allowed on proxied Elasticsearch datasource") + } + if proxy.ctx.Req.Request.Method == "POST" && proxy.proxyPath != "_msearch" { + return errors.New("Posts not allowed on proxied Elasticsearch datasource except on /_msearch") + } + } + + // found route if there are any + if len(proxy.plugin.Routes) > 0 { + for _, route := range proxy.plugin.Routes { + // method match + if route.Method != "" && route.Method != "*" && route.Method != proxy.ctx.Req.Method { + continue + } + + if route.ReqRole.IsValid() { + if !proxy.ctx.HasUserRole(route.ReqRole) { + return errors.New("Plugin proxy route access denied") + } + } + + if strings.HasPrefix(proxy.proxyPath, route.Path) { + proxy.route = route + break + } + } + } + + return nil +} + +func (proxy *DataSourceProxy) logRequest() { + if !setting.DataProxyLogging { + return + } + + var body string + if proxy.ctx.Req.Request.Body != nil { + buffer, err := ioutil.ReadAll(proxy.ctx.Req.Request.Body) + if err == nil { + proxy.ctx.Req.Request.Body = ioutil.NopCloser(bytes.NewBuffer(buffer)) + body = string(buffer) + } + } + + logger.Info("Proxying incoming request", + "userid", proxy.ctx.UserId, + "orgid", proxy.ctx.OrgId, + "username", proxy.ctx.Login, + "datasource", proxy.ds.Type, + "uri", proxy.ctx.Req.RequestURI, + "method", proxy.ctx.Req.Request.Method, + "body", body) +} + +func checkWhiteList(c *middleware.Context, host string) bool { + if host != "" && len(setting.DataProxyWhiteList) > 0 { + if _, exists := setting.DataProxyWhiteList[host]; !exists { + c.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil) + return false + } + } + + return true +} + +func (proxy *DataSourceProxy) applyRoute(req *http.Request) { + proxy.proxyPath = strings.TrimPrefix(proxy.proxyPath, proxy.route.Path) + + data := templateData{ + JsonData: proxy.ds.JsonData.Interface().(map[string]interface{}), + SecureJsonData: proxy.ds.SecureJsonData.Decrypt(), + } + + routeUrl, err := url.Parse(proxy.route.Url) + if err != nil { + logger.Error("Error parsing plugin route url") + return + } + + req.URL.Scheme = routeUrl.Scheme + req.URL.Host = routeUrl.Host + req.Host = routeUrl.Host + req.URL.Path = util.JoinUrlFragments(routeUrl.Path, proxy.proxyPath) + + if err := addHeaders(&req.Header, proxy.route, data); err != nil { + logger.Error("Failed to render plugin headers", "error", err) + } + + if proxy.route.TokenAuth != nil { + if token, err := proxy.getAccessToken(data); err != nil { + logger.Error("Failed to get access token", "error", err) + } else { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + } + } + + logger.Info("Requesting", "url", req.URL.String()) +} + +func (proxy *DataSourceProxy) getAccessToken(data templateData) (string, error) { + if cachedToken, found := tokenCache[proxy.ds.Id]; found { + if cachedToken.ExpiresOn.After(time.Now().Add(time.Second * 10)) { + logger.Info("Using token from cache") + return cachedToken.AccessToken, nil + } + } + + urlInterpolated, err := interpolateString(proxy.route.TokenAuth.Url, data) + if err != nil { + return "", err + } + + params := make(url.Values) + for key, value := range proxy.route.TokenAuth.Params { + if interpolatedParam, err := interpolateString(value, data); err != nil { + return "", err + } else { + params.Add(key, interpolatedParam) + } + } + + getTokenReq, _ := http.NewRequest("POST", urlInterpolated, bytes.NewBufferString(params.Encode())) + getTokenReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + getTokenReq.Header.Add("Content-Length", strconv.Itoa(len(params.Encode()))) + + resp, err := client.Do(getTokenReq) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + var token jwtToken + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return "", err + } + + expiresOnEpoch, _ := strconv.ParseInt(token.ExpiresOnString, 10, 64) + token.ExpiresOn = time.Unix(expiresOnEpoch, 0) + tokenCache[proxy.ds.Id] = &token + + logger.Info("Got new access token", "ExpiresOn", token.ExpiresOn) + return token.AccessToken, nil +} + +func interpolateString(text string, data templateData) (string, error) { + t, err := template.New("content").Parse(text) + if err != nil { + return "", errors.New(fmt.Sprintf("Could not parse template %s.", text)) + } + + var contentBuf bytes.Buffer + err = t.Execute(&contentBuf, data) + if err != nil { + return "", errors.New(fmt.Sprintf("Failed to execute template %s.", text)) + } + + return contentBuf.String(), nil +} + +func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error { + for _, header := range route.Headers { + interpolated, err := interpolateString(header.Content, data) + if err != nil { + return err + } + reqHeaders.Add(header.Name, interpolated) + } + + return nil +} diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go new file mode 100644 index 00000000000..0a900ad3a6d --- /dev/null +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -0,0 +1,165 @@ +package pluginproxy + +import ( + "net/http" + "net/url" + "testing" + + macaron "gopkg.in/macaron.v1" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + . "github.com/smartystreets/goconvey/convey" +) + +func TestDSRouteRule(t *testing.T) { + + Convey("DataSourceProxy", t, func() { + Convey("Plugin with routes", func() { + plugin := &plugins.DataSourcePlugin{ + Routes: []*plugins.AppPluginRoute{ + { + Path: "api/v4/", + Url: "https://www.google.com", + ReqRole: m.ROLE_EDITOR, + Headers: []plugins.AppPluginRouteHeader{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, + }, + }, + { + Path: "api/admin", + Url: "https://www.google.com", + ReqRole: m.ROLE_ADMIN, + Headers: []plugins.AppPluginRouteHeader{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, + }, + }, + { + Path: "api/anon", + Url: "https://www.google.com", + Headers: []plugins.AppPluginRouteHeader{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, + }, + }, + }, + } + + setting.SecretKey = "password" + key, _ := util.Encrypt([]byte("123"), "password") + + ds := &m.DataSource{ + JsonData: simplejson.NewFromAny(map[string]interface{}{ + "clientId": "asd", + }), + SecureJsonData: map[string][]byte{ + "key": key, + }, + } + + req, _ := http.NewRequest("GET", "http://localhost/asd", nil) + ctx := &middleware.Context{ + Context: &macaron.Context{ + Req: macaron.Request{Request: req}, + }, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR}, + } + + Convey("When matching route path", func() { + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") + proxy.route = plugin.Routes[0] + proxy.applyRoute(req) + + Convey("should add headers and update url", func() { + So(req.URL.String(), ShouldEqual, "https://www.google.com/some/method") + So(req.Header.Get("x-header"), ShouldEqual, "my secret 123") + }) + }) + + Convey("Validating request", func() { + Convey("plugin route with valid role", func() { + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") + err := proxy.validateRequest() + So(err, ShouldBeNil) + }) + + Convey("plugin route with admin role and user is editor", func() { + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin") + err := proxy.validateRequest() + So(err, ShouldNotBeNil) + }) + + Convey("plugin route with admin role and user is admin", func() { + ctx.SignedInUser.OrgRole = m.ROLE_ADMIN + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin") + err := proxy.validateRequest() + So(err, ShouldBeNil) + }) + }) + }) + + Convey("When proxying graphite", func() { + plugin := &plugins.DataSourcePlugin{} + ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE} + ctx := &middleware.Context{} + + proxy := NewDataSourceProxy(ds, plugin, ctx, "/render") + + requestUrl, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestUrl} + + proxy.getDirector()(&req) + + Convey("Can translate request url and path", func() { + So(req.URL.Host, ShouldEqual, "graphite:8080") + So(req.URL.Path, ShouldEqual, "/render") + }) + }) + + Convey("When proxying InfluxDB", func() { + plugin := &plugins.DataSourcePlugin{} + + ds := &m.DataSource{ + Type: m.DS_INFLUXDB_08, + Url: "http://influxdb:8083", + Database: "site", + User: "user", + Password: "password", + } + + ctx := &middleware.Context{} + proxy := NewDataSourceProxy(ds, plugin, ctx, "") + + requestUrl, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestUrl} + + proxy.getDirector()(&req) + + Convey("Should add db to url", func() { + So(req.URL.Path, ShouldEqual, "/db/site/") + }) + + Convey("Should add username and password", func() { + queryVals := req.URL.Query() + So(queryVals["u"][0], ShouldEqual, "user") + So(queryVals["p"][0], ShouldEqual, "password") + }) + }) + + Convey("When interpolating string", func() { + data := templateData{ + SecureJsonData: map[string]string{ + "Test": "0asd+asd", + }, + } + + interpolated, err := interpolateString("{{.SecureJsonData.Test}}", data) + So(err, ShouldBeNil) + So(interpolated, ShouldEqual, "0asd+asd") + }) + + }) +} diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index a5139bb69f7..59138884228 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -1,15 +1,11 @@ package pluginproxy import ( - "bytes" "encoding/json" - "errors" - "fmt" "net" "net/http" "net/http/httputil" "net/url" - "text/template" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" @@ -38,23 +34,8 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appId string) (http. SecureJsonData: query.Result.SecureJsonData.Decrypt(), } - for _, header := range route.Headers { - var contentBuf bytes.Buffer - t, err := template.New("content").Parse(header.Content) - if err != nil { - return nil, errors.New(fmt.Sprintf("could not parse header content template for header %s.", header.Name)) - } - - err = t.Execute(&contentBuf, data) - if err != nil { - return nil, errors.New(fmt.Sprintf("failed to execute header content template for header %s.", header.Name)) - } - - log.Trace("Adding header to proxy request. %s: %s", header.Name, contentBuf.String()) - result.Add(header.Name, contentBuf.String()) - } - - return result, nil + err := addHeaders(&result, route, data) + return result, err } func NewApiPluginProxy(ctx *middleware.Context, proxyPath string, route *plugins.AppPluginRoute, appId string) *httputil.ReverseProxy { diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index fe7122f8557..042c03f9832 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -147,15 +147,16 @@ func GetPluginDashboards(c *middleware.Context) Response { } } -func GetPluginReadme(c *middleware.Context) Response { +func GetPluginMarkdown(c *middleware.Context) Response { pluginId := c.Params(":pluginId") + name := c.Params(":name") - if content, err := plugins.GetPluginReadme(pluginId); err != nil { + if content, err := plugins.GetPluginMarkdown(pluginId, name); err != nil { if notfound, ok := err.(plugins.PluginNotFoundError); ok { return ApiError(404, notfound.Error(), nil) } - return ApiError(500, "Could not get readme", err) + return ApiError(500, "Could not get markdown file", err) } else { return Respond(200, content) } diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 9245880c30f..4551860a680 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -3,10 +3,8 @@ package main import ( "flag" "fmt" - "io/ioutil" "os" "os/signal" - "path/filepath" "runtime" "runtime/trace" "strconv" @@ -16,7 +14,6 @@ import ( "net/http" _ "net/http/pprof" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" @@ -87,46 +84,11 @@ func main() { server.Start() } -func initRuntime() { - err := setting.NewConfigContext(&setting.CommandLineArgs{ - Config: *configFile, - HomePath: *homePath, - Args: flag.Args(), - }) - - if err != nil { - log.Fatal(3, err.Error()) - } - - logger := log.New("main") - logger.Info("Starting Grafana", "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) - - setting.LogConfigurationInfo() -} - func initSql() { sqlstore.NewEngine() sqlstore.EnsureAdminUser() } -func writePIDFile() { - if *pidFile == "" { - return - } - - // Ensure the required directory structure exists. - err := os.MkdirAll(filepath.Dir(*pidFile), 0700) - if err != nil { - log.Fatal(3, "Failed to verify pid directory", err) - } - - // Retrieve the PID and write it. - pid := strconv.Itoa(os.Getpid()) - if err := ioutil.WriteFile(*pidFile, []byte(pid), 0644); err != nil { - log.Fatal(3, "Failed to write pidfile", err) - } -} - func listenToSystemSignals(server models.GrafanaServer) { signalChan := make(chan os.Signal, 1) ignoreChan := make(chan os.Signal, 1) diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 30da2342c2b..eab55ec3d9d 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -2,7 +2,12 @@ package main import ( "context" + "flag" + "io/ioutil" "os" + "path/filepath" + "strconv" + "time" "golang.org/x/sync/errgroup" @@ -45,8 +50,9 @@ type GrafanaServerImpl struct { func (g *GrafanaServerImpl) Start() { go listenToSystemSignals(g) - writePIDFile() - initRuntime() + g.initLogging() + g.writePIDFile() + initSql() metrics.Init() search.Init() @@ -74,6 +80,22 @@ func (g *GrafanaServerImpl) Start() { g.startHttpServer() } +func (g *GrafanaServerImpl) initLogging() { + err := setting.NewConfigContext(&setting.CommandLineArgs{ + Config: *configFile, + HomePath: *homePath, + Args: flag.Args(), + }) + + if err != nil { + g.log.Error(err.Error()) + os.Exit(1) + } + + g.log.Info("Starting Grafana", "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) + setting.LogConfigurationInfo() +} + func (g *GrafanaServerImpl) startHttpServer() { g.httpServer = api.NewHttpServer() @@ -101,3 +123,25 @@ func (g *GrafanaServerImpl) Shutdown(code int, reason string) { log.Close() os.Exit(code) } + +func (g *GrafanaServerImpl) writePIDFile() { + if *pidFile == "" { + return + } + + // Ensure the required directory structure exists. + err := os.MkdirAll(filepath.Dir(*pidFile), 0700) + if err != nil { + g.log.Error("Failed to verify pid directory", "error", err) + os.Exit(1) + } + + // Retrieve the PID and write it. + pid := strconv.Itoa(os.Getpid()) + if err := ioutil.WriteFile(*pidFile, []byte(pid), 0644); err != nil { + g.log.Error("Failed to write pidfile", "error", err) + os.Exit(1) + } + + g.log.Info("Writing PID file", "path", *pidFile, "pid", pid) +} diff --git a/pkg/components/dashdiffs/formatter_basic.go b/pkg/components/dashdiffs/formatter_basic.go index af93ff42bf2..4c8e03252a3 100644 --- a/pkg/components/dashdiffs/formatter_basic.go +++ b/pkg/components/dashdiffs/formatter_basic.go @@ -231,7 +231,7 @@ func (b *BasicDiff) Basic(lines []*JSONLine) []*BasicBlock { // in the JSON document to a top level key. // // In order to produce distinct "blocks" when rendering the basic diff, -// we need a way to distinguish between differnt sections of data. +// we need a way to distinguish between different sections of data. // To do this, we consider the value(s) of each top-level JSON key to // represent a distinct block for Grafana's JSON data structure, so // we perform this check to see if we've entered a new "block". If we diff --git a/pkg/components/dashdiffs/formatter_json.go b/pkg/components/dashdiffs/formatter_json.go index a2807b15992..3a9ddcc4ee3 100644 --- a/pkg/components/dashdiffs/formatter_json.go +++ b/pkg/components/dashdiffs/formatter_json.go @@ -302,16 +302,16 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi return nil } -func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, postion diff.Position) (results []diff.Delta) { +func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, position diff.Position) (results []diff.Delta) { results = make([]diff.Delta, 0) for _, delta := range deltas { switch delta.(type) { case diff.PostDelta: - if delta.(diff.PostDelta).PostPosition() == postion { + if delta.(diff.PostDelta).PostPosition() == position { results = append(results, delta) } case diff.PreDelta: - if delta.(diff.PreDelta).PrePosition() == postion { + if delta.(diff.PreDelta).PrePosition() == position { results = append(results, delta) } default: diff --git a/pkg/login/auth.go b/pkg/login/auth.go index a71837e25bc..45561783e43 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -33,8 +33,8 @@ func AuthenticateUser(query *LoginUserQuery) error { if setting.LdapEnabled { for _, server := range LdapCfg.Servers { - auther := NewLdapAuthenticator(server) - err = auther.Login(query) + author := NewLdapAuthenticator(server) + err = author.Login(query) if err == nil || err != ErrInvalidCredentials { return err } diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index b5921e6a534..9121865755e 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -57,6 +57,7 @@ var ( M_Alerting_Notification_Sent_Pushover Counter M_Aws_CloudWatch_GetMetricStatistics Counter M_Aws_CloudWatch_ListMetrics Counter + M_DB_DataSource_QueryById Counter // Timers M_DataSource_ProxyReq_Timer Timer @@ -135,6 +136,8 @@ func initMetricVars(settings *MetricSettings) { M_Aws_CloudWatch_GetMetricStatistics = RegCounter("aws.cloudwatch.get_metric_statistics") M_Aws_CloudWatch_ListMetrics = RegCounter("aws.cloudwatch.list_metrics") + M_DB_DataSource_QueryById = RegCounter("db.datasource.query_by_id") + // Timers M_DataSource_ProxyReq_Timer = RegTimer("api.dataproxy.request.all") M_Alerting_Execution_Time = RegTimer("alerting.execution_time") diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index 8e94e1582b0..3247805ec09 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -67,7 +67,7 @@ func initContextWithAuthProxy(ctx *Context, orgId int64) bool { if getRequestUserId(ctx) > 0 && getRequestUserId(ctx) != query.Result.UserId { // remove session if err := ctx.Session.Destory(ctx); err != nil { - log.Error(3, "Failed to destory session, err") + log.Error(3, "Failed to destroy session, err") } // initialize a new session @@ -107,8 +107,8 @@ var syncGrafanaUserWithLdapUser = func(ctx *Context, query *m.GetSignedInUserQue ldapCfg := login.LdapCfg for _, server := range ldapCfg.Servers { - auther := login.NewLdapAuthenticator(server) - if err := auther.SyncSignedInUser(query.Result); err != nil { + author := login.NewLdapAuthenticator(server) + if err := author.SyncSignedInUser(query.Result); err != nil { return err } } diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go index cc588bf1d55..b070ba592f0 100644 --- a/pkg/plugins/app_plugin.go +++ b/pkg/plugins/app_plugin.go @@ -23,12 +23,12 @@ type AppPlugin struct { } type AppPluginRoute struct { - Path string `json:"path"` - Method string `json:"method"` - ReqGrafanaAdmin bool `json:"reqGrafanaAdmin"` - ReqRole models.RoleType `json:"reqRole"` - Url string `json:"url"` - Headers []AppPluginRouteHeader `json:"headers"` + Path string `json:"path"` + Method string `json:"method"` + ReqRole models.RoleType `json:"reqRole"` + Url string `json:"url"` + Headers []AppPluginRouteHeader `json:"headers"` + TokenAuth *JwtTokenAuth `json:"tokenAuth"` } type AppPluginRouteHeader struct { @@ -36,6 +36,11 @@ type AppPluginRouteHeader struct { Content string `json:"content"` } +type JwtTokenAuth struct { + Url string `json:"url"` + Params map[string]string `json:"params"` +} + func (app *AppPlugin) Load(decoder *json.Decoder, pluginDir string) error { if err := decoder.Decode(&app); err != nil { return err diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index aa092c2bc20..b36d7081dfe 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -1,15 +1,21 @@ package plugins -import "encoding/json" +import ( + "encoding/json" + "os" + "path/filepath" +) type DataSourcePlugin struct { FrontendPluginBase - Annotations bool `json:"annotations"` - Metrics bool `json:"metrics"` - Alerting bool `json:"alerting"` - BuiltIn bool `json:"builtIn"` - Mixed bool `json:"mixed"` - App string `json:"app"` + Annotations bool `json:"annotations"` + Metrics bool `json:"metrics"` + Alerting bool `json:"alerting"` + QueryOptions map[string]bool `json:"queryOptions,omitempty"` + BuiltIn bool `json:"builtIn,omitempty"` + Mixed bool `json:"mixed,omitempty"` + HasQueryHelp bool `json:"hasQueryHelp,omitempty"` + Routes []*AppPluginRoute `json:"routes"` } func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error { @@ -21,6 +27,15 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error { return err } + // look for help markdown + helpPath := filepath.Join(p.PluginDir, "QUERY_HELP.md") + if _, err := os.Stat(helpPath); os.IsNotExist(err) { + helpPath = filepath.Join(p.PluginDir, "query_help.md") + } + if _, err := os.Stat(helpPath); err == nil { + p.HasQueryHelp = true + } + DataSources[p.Id] = p return nil } diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index fd04852f2f4..541b37c8a8a 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -38,8 +38,8 @@ type PluginBase struct { Includes []*PluginInclude `json:"includes"` Module string `json:"module"` BaseUrl string `json:"baseUrl"` - HideFromList bool `json:"hideFromList"` - State string `json:"state"` + HideFromList bool `json:"hideFromList,omitempty"` + State string `json:"state,omitempty"` IncludedInAppId string `json:"-"` PluginDir string `json:"-"` @@ -48,9 +48,6 @@ type PluginBase struct { GrafanaNetVersion string `json:"-"` GrafanaNetHasUpdate bool `json:"-"` - - // cache for readme file contents - Readme []byte `json:"-"` } func (pb *PluginBase) registerPlugin(pluginDir string) error { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index b6c3639cbbf..885bd5c9e03 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -3,6 +3,7 @@ package plugins import ( "encoding/json" "errors" + "fmt" "io/ioutil" "os" "path" @@ -166,30 +167,24 @@ func (scanner *PluginScanner) loadPluginJson(pluginJsonFilePath string) error { return loader.Load(jsonParser, currentDir) } -func GetPluginReadme(pluginId string) ([]byte, error) { +func GetPluginMarkdown(pluginId string, name string) ([]byte, error) { plug, exists := Plugins[pluginId] if !exists { return nil, PluginNotFoundError{pluginId} } - if plug.Readme != nil { - return plug.Readme, nil + path := filepath.Join(plug.PluginDir, fmt.Sprintf("%s.md", strings.ToUpper(name))) + if _, err := os.Stat(path); os.IsNotExist(err) { + path = filepath.Join(plug.PluginDir, fmt.Sprintf("%s.md", strings.ToLower(name))) } - readmePath := filepath.Join(plug.PluginDir, "README.md") - if _, err := os.Stat(readmePath); os.IsNotExist(err) { - readmePath = filepath.Join(plug.PluginDir, "readme.md") + if _, err := os.Stat(path); os.IsNotExist(err) { + return make([]byte, 0), nil } - if _, err := os.Stat(readmePath); os.IsNotExist(err) { - plug.Readme = make([]byte, 0) - return plug.Readme, nil - } - - if readmeBytes, err := ioutil.ReadFile(readmePath); err != nil { + if data, err := ioutil.ReadFile(path); err != nil { return nil, err } else { - plug.Readme = readmeBytes - return plug.Readme, nil + return data, nil } } diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index e0a6035355d..ae8008deb75 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -104,7 +104,7 @@ func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange * } if err := bus.Dispatch(getDsInfo); err != nil { - return nil, fmt.Errorf("Could not find datasource") + return nil, fmt.Errorf("Could not find datasource %v", err) } req := c.getRequestForAlertRule(getDsInfo.Result, timeRange) diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index 0eb21865b9f..aefa7de6ede 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -84,7 +84,7 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { return err } - message := evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + "
Check Dasboard" + message := evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + "
Check Dashboard" fields := make([]map[string]interface{}, 0) message += "
" for index, evt := range evalContext.EvalMatches { diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 831bead2360..5d8e0e049ae 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/securejsondata" + "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" ) @@ -19,6 +20,8 @@ func init() { } func GetDataSourceById(query *m.GetDataSourceByIdQuery) error { + metrics.M_DB_DataSource_QueryById.Inc(1) + datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id} has, err := x.Get(&datasource) diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index b45f97875f8..e2fc1f29063 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -114,7 +114,7 @@ func getEngine() (*xorm.Engine, error) { protocol = "unix" } - cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4", + cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true", DbCfg.User, DbCfg.Pwd, protocol, DbCfg.Host, DbCfg.Name) if DbCfg.SslMode == "true" || DbCfg.SslMode == "skip-verify" { diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 68ced55722c..4aa2ec27216 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -12,7 +12,7 @@ type TestDB struct { } var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} -var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?charset=utf8mb4"} +var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} func CleanDB(x *xorm.Engine) { diff --git a/pkg/tsdb/influxdb/query.go b/pkg/tsdb/influxdb/query.go index f271c5d245f..0bcdf1d5291 100644 --- a/pkg/tsdb/influxdb/query.go +++ b/pkg/tsdb/influxdb/query.go @@ -151,8 +151,12 @@ func (query *Query) renderMeasurement() string { func (query *Query) renderWhereClause() string { res := " WHERE " conditions := query.renderTags() - res += strings.Join(conditions, " ") if len(conditions) > 0 { + if len(conditions) > 1 { + res += "(" + strings.Join(conditions, " ") + ")" + } else { + res += conditions[0] + } res += " AND " } diff --git a/pkg/tsdb/influxdb/query_test.go b/pkg/tsdb/influxdb/query_test.go index 533df27f91f..0977ef8c1ce 100644 --- a/pkg/tsdb/influxdb/query_test.go +++ b/pkg/tsdb/influxdb/query_test.go @@ -57,7 +57,7 @@ func TestInfluxdbQueryBuilder(t *testing.T) { rawQuery, err := query.Build(queryContext) So(err, ShouldBeNil) - So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE "hostname" = 'server1' OR "hostname" = 'server2' AND time > now() - 5m GROUP BY time(5s), "datacenter" fill(null)`) + So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE ("hostname" = 'server1' OR "hostname" = 'server2') AND time > now() - 5m GROUP BY time(5s), "datacenter" fill(null)`) }) Convey("can build query with math part", func() { diff --git a/pkg/tsdb/mqe/types_test.go b/pkg/tsdb/mqe/types_test.go index fccd6709bd4..6f716937f1e 100644 --- a/pkg/tsdb/mqe/types_test.go +++ b/pkg/tsdb/mqe/types_test.go @@ -71,7 +71,7 @@ func TestWildcardExpansion(t *testing.T) { So(expandeQueries[0].RawQuery, ShouldEqual, fmt.Sprintf("`os.cpu.3.idle`|aggregate.min|aggregate.max where cluster in ('demoapp-1', 'demoapp-2') and host in ('staples-lab-1', 'staples-lab-2') from %v to %v", from, to)) }) - Convey("Containg wildcard series", func() { + Convey("Containing wildcard series", func() { query := &Query{ Metrics: []Metric{ {Metric: "os.cpu*", Alias: ""}, diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 09ded090089..e75dab8c04f 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -1,6 +1,7 @@ package mysql import ( + "container/list" "context" "database/sql" "fmt" @@ -65,7 +66,7 @@ func (e *MysqlExecutor) initEngine() error { } } - cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=true&loc=UTC", e.datasource.User, e.datasource.Password, "tcp", e.datasource.Url, e.datasource.Database) + cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC", e.datasource.User, e.datasource.Password, "tcp", e.datasource.Url, e.datasource.Database) e.log.Debug("getEngine", "connection", cnnstr) engine, err := xorm.NewEngine("mysql", cnnstr) @@ -245,6 +246,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows) func (e MysqlExecutor) TransformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { pointsBySeries := make(map[string]*tsdb.TimeSeries) + seriesByQueryOrder := list.New() columnNames, err := rows.Columns() if err != nil { @@ -282,11 +284,13 @@ func (e MysqlExecutor) TransformToTimeSeries(query *tsdb.Query, rows *core.Rows, series := &tsdb.TimeSeries{Name: rowData.metric} series.Points = append(series.Points, tsdb.TimePoint{rowData.value, rowData.time}) pointsBySeries[rowData.metric] = series + seriesByQueryOrder.PushBack(rowData.metric) } } - for _, value := range pointsBySeries { - result.Series = append(result.Series, value) + for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() { + key := elem.Value.(string) + result.Series = append(result.Series, pointsBySeries[key]) } result.Meta.Set("rowCount", rowCount) diff --git a/public/app/core/components/code_editor/code_editor.ts b/public/app/core/components/code_editor/code_editor.ts new file mode 100644 index 00000000000..151ebffab5d --- /dev/null +++ b/public/app/core/components/code_editor/code_editor.ts @@ -0,0 +1,209 @@ +/** + * codeEditor directive based on Ace code editor + * https://github.com/ajaxorg/ace + * + * Basic usage: + * + * + * + * Params: + * content: Editor content. + * onChange: Function called on content change (invoked on editor blur, ctrl+enter, not on every change). + * getCompleter: Function returned external completer. Completer is an object implemented getCompletions() method, + * see Prometheus Data Source implementation for details. + * + * Some Ace editor options available via data-* attributes: + * data-mode - Language mode (text, sql, javascript, etc.). Default is 'text'. + * data-theme - Editor theme (eg 'solarized_dark'). + * data-max-lines - Max editor height in lines. Editor grows automatically from 1 to maxLines. + * data-show-gutter - Show gutter (contains line numbers and additional info). + * data-tab-size - Tab size, default is 2. + * data-behaviours-enabled - Specifies whether to use behaviors or not. "Behaviors" in this case is the auto-pairing of + * special characters, like quotation marks, parenthesis, or brackets. + * + * Keybindings: + * Ctrl-Enter (Command-Enter): run onChange() function + */ + +/// +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import config from 'app/core/config'; +import ace from 'ace'; + +const ACE_SRC_BASE = "public/vendor/npm/ace-builds/src-noconflict/"; +const DEFAULT_THEME_DARK = "grafana-dark"; +const DEFAULT_THEME_LIGHT = "textmate"; +const DEFAULT_MODE = "text"; +const DEFAULT_MAX_LINES = 10; +const DEFAULT_TAB_SIZE = 2; +const DEFAULT_BEHAVIOURS = true; + +const GRAFANA_MODULES = ['mode-prometheus', 'snippets-prometheus', 'theme-grafana-dark']; +const GRAFANA_MODULE_BASE = "public/app/core/components/code_editor/"; + +// Trick for loading additional modules +function setModuleUrl(moduleType, name) { + let baseUrl = ACE_SRC_BASE; + let aceModeName = `ace/${moduleType}/${name}`; + let moduleName = `${moduleType}-${name}`; + let componentName = `${moduleName}.js`; + + if (_.includes(GRAFANA_MODULES, moduleName)) { + baseUrl = GRAFANA_MODULE_BASE; + } + + if (moduleType === 'snippets') { + componentName = `${moduleType}/${name}.js`; + } + + ace.config.setModuleUrl(aceModeName, baseUrl + componentName); +} + +setModuleUrl("ext", "language_tools"); +setModuleUrl("mode", "text"); +setModuleUrl("snippets", "text"); + +let editorTemplate = `
`; + +function link(scope, elem, attrs) { + let lightTheme = config.bootData.user.lightTheme; + let default_theme = lightTheme ? DEFAULT_THEME_LIGHT : DEFAULT_THEME_DARK; + + // Options + let langMode = attrs.mode || DEFAULT_MODE; + let maxLines = attrs.maxLines || DEFAULT_MAX_LINES; + let showGutter = attrs.showGutter !== undefined; + let theme = attrs.theme || default_theme; + let tabSize = attrs.tabSize || DEFAULT_TAB_SIZE; + let behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS; + + // Initialize editor + let aceElem = elem.get(0); + let codeEditor = ace.edit(aceElem); + let editorSession = codeEditor.getSession(); + + let editorOptions = { + maxLines: maxLines, + showGutter: showGutter, + tabSize: tabSize, + behavioursEnabled: behavioursEnabled, + highlightActiveLine: false, + showPrintMargin: false, + autoScrollEditorIntoView: true // this is needed if editor is inside scrollable page + }; + + // Set options + codeEditor.setOptions(editorOptions); + // disable depreacation warning + codeEditor.$blockScrolling = Infinity; + // Padding hacks + codeEditor.renderer.setScrollMargin(15, 15); + codeEditor.renderer.setPadding(10); + + setThemeMode(theme); + setLangMode(langMode); + setEditorContent(scope.content); + + // Add classes + elem.addClass("gf-code-editor"); + let textarea = elem.find("textarea"); + textarea.addClass('gf-form-input'); + + // Event handlers + editorSession.on('change', (e) => { + scope.$apply(() => { + let newValue = codeEditor.getValue(); + scope.content = newValue; + }); + }); + + // Sync with outer scope - update editor content if model has been changed from outside of directive. + scope.$watch('content', (newValue, oldValue) => { + let editorValue = codeEditor.getValue(); + if (newValue !== editorValue && newValue !== oldValue) { + scope.$$postDigest(function() { + setEditorContent(newValue); + }); + } + }); + + codeEditor.on('blur', () => { + scope.onChange(); + }); + + scope.$on("$destroy", () => { + codeEditor.destroy(); + }); + + // Keybindings + codeEditor.commands.addCommand({ + name: 'executeQuery', + bindKey: {win: 'Ctrl-Enter', mac: 'Command-Enter'}, + exec: () => { + scope.onChange(); + } + }); + + function setLangMode(lang) { + let aceModeName = `ace/mode/${lang}`; + setModuleUrl("mode", lang); + setModuleUrl("snippets", lang); + editorSession.setMode(aceModeName); + + ace.config.loadModule("ace/ext/language_tools", (language_tools) => { + codeEditor.setOptions({ + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, + enableSnippets: true + }); + + if (scope.getCompleter()) { + // make copy of array as ace seems to share completers array between instances + codeEditor.completers = codeEditor.completers.slice(); + codeEditor.completers.push(scope.getCompleter()); + } + }); + } + + function setThemeMode(theme) { + setModuleUrl("theme", theme); + let themeModule = `ace/theme/${theme}`; + ace.config.loadModule(themeModule, (theme_module) => { + // Check is theme light or dark and fix if needed + let lightTheme = config.bootData.user.lightTheme; + let fixedTheme = theme; + if (lightTheme && theme_module.isDark) { + fixedTheme = DEFAULT_THEME_LIGHT; + } else if (!lightTheme && !theme_module.isDark) { + fixedTheme = DEFAULT_THEME_DARK; + } + setModuleUrl("theme", fixedTheme); + themeModule = `ace/theme/${fixedTheme}`; + codeEditor.setTheme(themeModule); + + elem.addClass("gf-code-editor--theme-loaded"); + }); + } + + function setEditorContent(value) { + codeEditor.setValue(value); + codeEditor.clearSelection(); + } +} + +export function codeEditorDirective() { + return { + restrict: 'E', + template: editorTemplate, + scope: { + content: "=", + onChange: "&", + getCompleter: "&" + }, + link: link + }; +} + +coreModule.directive('codeEditor', codeEditorDirective); diff --git a/public/app/core/components/code_editor/mode-prometheus.js b/public/app/core/components/code_editor/mode-prometheus.js new file mode 100644 index 00000000000..4843b952253 --- /dev/null +++ b/public/app/core/components/code_editor/mode-prometheus.js @@ -0,0 +1,513 @@ +// jshint ignore: start +// jscs: disable +ace.define("ace/mode/prometheus_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module) { +"use strict"; + +var oop = require("../lib/oop"); +var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules; + +var PrometheusHighlightRules = function() { + var keywords = ( + "by|without|keep_common|offset|bool|and|or|unless|ignoring|on|group_left|group_right|" + + "count|count_values|min|max|avg|sum|stddev|stdvar|bottomk|topk|quantile" + ); + + var builtinConstants = ( + "true|false|null|__name__|job" + ); + + var builtinFunctions = ( + "abs|absent|ceil|changes|clamp_max|clamp_min|count_scalar|day_of_month|day_of_week|days_in_month|delta|deriv|" + "drop_common_labels|exp|floor|histogram_quantile|holt_winters|hour|idelta|increase|irate|label_replace|ln|log2|" + + "log10|minute|month|predict_linear|rate|resets|round|scalar|sort|sort_desc|sqrt|time|vector|year|avg_over_time|" + + "min_over_time|max_over_time|sum_over_time|count_over_time|quantile_over_time|stddev_over_time|stdvar_over_time" + ); + + var keywordMapper = this.createKeywordMapper({ + "support.function": builtinFunctions, + "keyword": keywords, + "constant.language": builtinConstants + }, "identifier", true); + + this.$rules = { + "start" : [ { + token : "string", // single line + regex : /"(?:[^"\\]|\\.)*?"/ + }, { + token : "string", // string + regex : "'.*?'" + }, { + token : "constant.numeric", // float + regex : "[-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b" + }, { + token : "constant.language", // time + regex : "\\d+[smhdwy]" + }, { + token : keywordMapper, + regex : "[a-zA-Z_$][a-zA-Z0-9_$]*\\b" + }, { + token : "keyword.operator", + regex : "\\+|\\-|\\*|\\/|%|\\^|=|==|!=|<=|>=|<|>|=\\~|!\\~" + }, { + token : "paren.lparen", + regex : "[[(]" + }, { + token : "paren.lparen", + regex : "{", + next : "start-label-matcher" + }, { + token : "paren.rparen", + regex : "[\\])]" + }, { + token : "paren.rparen", + regex : "}" + }, { + token : "text", + regex : "\\s+" + } ], + "start-label-matcher" : [ { + token : "label.name", + regex : '[a-zA-Z_][a-zA-Z0-9_]*' + }, { + token : "label.matching_operator", + regex : '=|!=|=~|!~' + }, { + token : "label.value", + regex : '"[^"]*"|\'[^\']*\'' + }, { + token : "label.matching_delimiter", + regex : ",", + push : 'start-label-matcher' + }, { + token : "label.matching_end", + regex : "}", + next : "start" + } ] + }; + + this.normalizeRules(); +}; + +oop.inherits(PrometheusHighlightRules, TextHighlightRules); + +exports.PrometheusHighlightRules = PrometheusHighlightRules; +}); + +ace.define("ace/mode/prometheus_completions",["require","exports","module","ace/token_iterator", "ace/lib/lang"], function(require, exports, module) { +"use strict"; + +var lang = require("../lib/lang"); + +var prometheusKeyWords = [ + "by", "without", "keep_common", "offset", "bool", "and", "or", "unless", "ignoring", "on", "group_left", + "group_right", "count", "count_values", "min", "max", "avg", "sum", "stddev", "stdvar", "bottomk", "topk", "quantile" +]; + +var keyWordsCompletions = prometheusKeyWords.map(function(word) { + return { + caption: word, + value: word, + meta: "keyword", + score: Number.MAX_VALUE + } +}); + +var prometheusFunctions = [ + { + name: 'abs()', value: 'abs', + def: 'abs(v instant-vector)', + docText: 'Returns the input vector with all sample values converted to their absolute value.' + }, + { + name: 'abs()', value: 'abs', + def: 'abs(v instant-vector)', + docText: 'Returns the input vector with all sample values converted to their absolute value.' + }, + { + name: 'absent()', value: 'absent', + def: 'absent(v instant-vector)', + docText: 'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.' + }, + { + name: 'ceil()', value: 'ceil', + def: 'ceil(v instant-vector)', + docText: 'Rounds the sample values of all elements in `v` up to the nearest integer.' + }, + { + name: 'changes()', value: 'changes', + def: 'changes(v range-vector)', + docText: 'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.' + }, + { + name: 'clamp_max()', value: 'clamp_max', + def: 'clamp_max(v instant-vector, max scalar)', + docText: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.' + }, + { + name: 'clamp_min()', value: 'clamp_min', + def: 'clamp_min(v instant-vector, min scalar)', + docText: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.' + }, + { + name: 'count_scalar()', value: 'count_scalar', + def: 'count_scalar(v instant-vector)', + docText: 'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.' + }, + { + name: 'day_of_month()', value: 'day_of_month', + def: 'day_of_month(v=vector(time()) instant-vector)', + docText: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.' + }, + { + name: 'day_of_week()', value: 'day_of_week', + def: 'day_of_week(v=vector(time()) instant-vector)', + docText: 'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.' + }, + { + name: 'days_in_month()', value: 'days_in_month', + def: 'days_in_month(v=vector(time()) instant-vector)', + docText: 'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.' + }, + { + name: 'delta()', value: 'delta', + def: 'delta(v range-vector)', + docText: 'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.' + }, + { + name: 'deriv()', value: 'deriv', + def: 'deriv(v range-vector)', + docText: 'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.' + }, + { + name: 'drop_common_labels()', value: 'drop_common_labels', + def: 'drop_common_labels(instant-vector)', + docText: 'Drops all labels that have the same name and value across all series in the input vector.' + }, + { + name: 'exp()', value: 'exp', + def: 'exp(v instant-vector)', + docText: 'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`' + }, + { + name: 'floor()', value: 'floor', + def: 'floor(v instant-vector)', + docText: 'Rounds the sample values of all elements in `v` down to the nearest integer.' + }, + { + name: 'histogram_quantile()', value: 'histogram_quantile', + def: 'histogram_quantile(φ float, b instant-vector)', + docText: 'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.' + }, + { + name: 'holt_winters()', value: 'holt_winters', + def: 'holt_winters(v range-vector, sf scalar, tf scalar)', + docText: 'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.' + }, + { + name: 'hour()', value: 'hour', + def: 'hour(v=vector(time()) instant-vector)', + docText: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.' + }, + { + name: 'idelta()', value: 'idelta', + def: 'idelta(v range-vector)', + docText: 'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.' + }, + { + name: 'increase()', value: 'increase', + def: 'increase(v range-vector)', + docText: 'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.' + }, + { + name: 'irate()', value: 'irate', + def: 'irate(v range-vector)', + docText: 'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.' + }, + { + name: 'label_replace()', value: 'label_replace', + def: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)', + docText: 'For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn\'t match then the timeseries is returned unchanged.' + }, + { + name: 'ln()', value: 'ln', + def: 'ln(v instant-vector)', + docText: 'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`' + }, + { + name: 'log2()', value: 'log2', + def: 'log2(v instant-vector)', + docText: 'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.' + }, + { + name: 'log10()', value: 'log10', + def: 'log10(v instant-vector)', + docText: 'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.' + }, + { + name: 'minute()', value: 'minute', + def: 'minute(v=vector(time()) instant-vector)', + docText: 'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.' + }, + { + name: 'month()', value: 'month', + def: 'month(v=vector(time()) instant-vector)', + docText: 'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.' + }, + { + name: 'predict_linear()', value: 'predict_linear', + def: 'predict_linear(v range-vector, t scalar)', + docText: 'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.' + }, + { + name: 'rate()', value: 'rate', + def: 'rate(v range-vector)', + docText: "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period." + }, + { + name: 'resets()', value: 'resets', + def: 'resets(v range-vector)', + docText: 'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.' + }, + { + name: 'round()', value: 'round', + def: 'round(v instant-vector, to_nearest=1 scalar)', + docText: 'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.' + }, + { + name: 'scalar()', value: 'scalar', + def: 'scalar(v instant-vector)', + docText: 'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.' + }, + { + name: 'sort()', value: 'sort', + def: 'sort(v instant-vector)', + docText: 'Returns vector elements sorted by their sample values, in ascending order.' + }, + { + name: 'sort_desc()', value: 'sort_desc', + def: 'sort_desc(v instant-vector)', + docText: 'Returns vector elements sorted by their sample values, in descending order.' + }, + { + name: 'sqrt()', value: 'sqrt', + def: 'sqrt(v instant-vector)', + docText: 'Calculates the square root of all elements in `v`.' + }, + { + name: 'time()', value: 'time', + def: 'time()', + docText: 'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.' + }, + { + name: 'vector()', value: 'vector', + def: 'vector(s scalar)', + docText: 'Returns the scalar `s` as a vector with no labels.' + }, + { + name: 'year()', value: 'year', + def: 'year(v=vector(time()) instant-vector)', + docText: 'Returns the year for each of the given times in UTC.' + }, + { + name: 'avg_over_time()', value: 'avg_over_time', + def: 'avg_over_time(range-vector)', + docText: 'The average value of all points in the specified interval.' + }, + { + name: 'min_over_time()', value: 'min_over_time', + def: 'min_over_time(range-vector)', + docText: 'The minimum value of all points in the specified interval.' + }, + { + name: 'max_over_time()', value: 'max_over_time', + def: 'max_over_time(range-vector)', + docText: 'The maximum value of all points in the specified interval.' + }, + { + name: 'sum_over_time()', value: 'sum_over_time', + def: 'sum_over_time(range-vector)', + docText: 'The sum of all values in the specified interval.' + }, + { + name: 'count_over_time()', value: 'count_over_time', + def: 'count_over_time(range-vector)', + docText: 'The count of all values in the specified interval.' + }, + { + name: 'quantile_over_time()', value: 'quantile_over_time', + def: 'quantile_over_time(scalar, range-vector)', + docText: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.' + }, + { + name: 'stddev_over_time()', value: 'stddev_over_time', + def: 'stddev_over_time(range-vector)', + docText: 'The population standard deviation of the values in the specified interval.' + }, + { + name: 'stdvar_over_time()', value: 'stdvar_over_time', + def: 'stdvar_over_time(range-vector)', + docText: 'The population standard variance of the values in the specified interval.' + }, +]; + +function wrapText(str, len) { + len = len || 60; + var lines = []; + var space_index = 0; + var line_start = 0; + var next_line_end = len; + var line = ""; + for (var i = 0; i < str.length; i++) { + if (str[i] === ' ') { + space_index = i; + } else if (i >= next_line_end && space_index != 0) { + line = str.slice(line_start, space_index); + lines.push(line); + line_start = space_index + 1; + next_line_end = i + len; + space_index = 0; + } + } + line = str.slice(line_start); + lines.push(line); + return lines.join(" 
"); +} + +function convertMarkDownTags(text) { + text = text.replace(/```(.+)```/, "
$1
"); + text = text.replace(/`([^`]+)`/, "$1"); + return text; +} + +function convertToHTML(item) { + var docText = lang.escapeHTML(item.docText); + docText = convertMarkDownTags(wrapText(docText, 40)); + return [ + "", lang.escapeHTML(item.def), "", "
", docText, "
 " + ].join(""); +} + +var functionsCompletions = prometheusFunctions.map(function(item) { + return { + caption: item.name, + value: item.value, + docHTML: convertToHTML(item), + meta: "function", + score: Number.MAX_VALUE + }; +}); + +var PrometheusCompletions = function() {}; + +(function() { + this.getCompletions = function(state, session, pos, prefix, callback) { + var token = session.getTokenAt(pos.row, pos.column); + if (token.type === 'label.name' || token.type === 'label.value') { + return callback(null, []); + } + + var completions = keyWordsCompletions.concat(functionsCompletions); + callback(null, completions); + }; + +}).call(PrometheusCompletions.prototype); + +exports.PrometheusCompletions = PrometheusCompletions; +}); + +ace.define("ace/mode/behaviour/prometheus",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/mode/behaviour/cstyle","ace/token_iterator"], function(require, exports, module) { +"use strict"; + +var oop = require("../../lib/oop"); +var Behaviour = require("../behaviour").Behaviour; +var CstyleBehaviour = require("./cstyle").CstyleBehaviour; +var TokenIterator = require("../../token_iterator").TokenIterator; + +function getWrapped(selection, selected, opening, closing) { + var rowDiff = selection.end.row - selection.start.row; + return { + text: opening + selected + closing, + selection: [ + 0, + selection.start.column + 1, + rowDiff, + selection.end.column + (rowDiff ? 0 : 1) + ] + }; +}; + +var PrometheusBehaviour = function () { + this.inherit(CstyleBehaviour); + + // Rewrite default CstyleBehaviour for {} braces + this.add("braces", "insertion", function(state, action, editor, session, text) { + if (text == '{') { + var selection = editor.getSelectionRange(); + var selected = session.doc.getTextRange(selection); + if (selected !== "" && editor.getWrapBehavioursEnabled()) { + return getWrapped(selection, selected, '{', '}'); + } else if (CstyleBehaviour.isSaneInsertion(editor, session)) { + return { + text: '{}', + selection: [1, 1] + }; + } + } else if (text == '}') { + var cursor = editor.getCursorPosition(); + var line = session.doc.getLine(cursor.row); + var rightChar = line.substring(cursor.column, cursor.column + 1); + if (rightChar == '}') { + var matching = session.$findOpeningBracket('}', {column: cursor.column + 1, row: cursor.row}); + if (matching !== null && CstyleBehaviour.isAutoInsertedClosing(cursor, line, text)) { + return { + text: '', + selection: [1, 1] + }; + } + } + } + }); + + this.add("braces", "deletion", function(state, action, editor, session, range) { + var selected = session.doc.getTextRange(range); + if (!range.isMultiLine() && selected == '{') { + var line = session.doc.getLine(range.start.row); + var rightChar = line.substring(range.start.column + 1, range.start.column + 2); + if (rightChar == '}') { + range.end.column++; + return range; + } + } + }); + +} +oop.inherits(PrometheusBehaviour, CstyleBehaviour); + +exports.PrometheusBehaviour = PrometheusBehaviour; +}); + +ace.define("ace/mode/prometheus",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/prometheus_highlight_rules"], function(require, exports, module) { +"use strict"; + +var oop = require("../lib/oop"); +var TextMode = require("./text").Mode; +var PrometheusHighlightRules = require("./prometheus_highlight_rules").PrometheusHighlightRules; +var PrometheusCompletions = require("./prometheus_completions").PrometheusCompletions; +var PrometheusBehaviour = require("./behaviour/prometheus").PrometheusBehaviour; + +var Mode = function() { + this.HighlightRules = PrometheusHighlightRules; + this.$behaviour = new PrometheusBehaviour(); + this.$completer = new PrometheusCompletions(); + // replace keyWordCompleter + this.completer = this.$completer; +}; +oop.inherits(Mode, TextMode); + +(function() { + + this.$id = "ace/mode/prometheus"; +}).call(Mode.prototype); + +exports.Mode = Mode; + +}); diff --git a/public/app/core/components/code_editor/snippets/prometheus.js b/public/app/core/components/code_editor/snippets/prometheus.js new file mode 100644 index 00000000000..eaaef482932 --- /dev/null +++ b/public/app/core/components/code_editor/snippets/prometheus.js @@ -0,0 +1,21 @@ +// jshint ignore: start +// jscs: disable +ace.define("ace/snippets/prometheus",["require","exports","module"], function(require, exports, module) { +"use strict"; + +// exports.snippetText = "# rate\n\ +// snippet r\n\ +// rate(${1:metric}[${2:range}])\n\ +// "; + +exports.snippets = [ + { + "content": "rate(${1:metric}[${2:range}])", + "name": "rate()", + "scope": "prometheus", + "tabTrigger": "r" + } +]; + +exports.scope = "prometheus"; +}); diff --git a/public/app/core/components/code_editor/theme-grafana-dark.js b/public/app/core/components/code_editor/theme-grafana-dark.js new file mode 100644 index 00000000000..621ec2c5efb --- /dev/null +++ b/public/app/core/components/code_editor/theme-grafana-dark.js @@ -0,0 +1,116 @@ +/* jshint ignore:start */ + +ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"], function(require, exports, module) { + "use strict"; + + exports.isDark = true; + exports.cssClass = "gf-code-dark"; + exports.cssText = ".gf-code-dark .ace_gutter {\ + background: #2f3129;\ + color: #8f908a\ + }\ + .gf-code-dark .ace_print-margin {\ + width: 1px;\ + background: #555651\ + }\ + .gf-code-dark {\ + background-color: #111;\ + color: #e0e0e0\ + }\ + .gf-code-dark .ace_cursor {\ + color: #f8f8f0\ + }\ + .gf-code-dark .ace_marker-layer .ace_selection {\ + background: #49483e\ + }\ + .gf-code-dark.ace_multiselect .ace_selection.ace_start {\ + box-shadow: 0 0 3px 0px #272822;\ + }\ + .gf-code-dark .ace_marker-layer .ace_step {\ + background: rgb(102, 82, 0)\ + }\ + .gf-code-dark .ace_marker-layer .ace_bracket {\ + margin: -1px 0 0 -1px;\ + border: 1px solid #49483e\ + }\ + .gf-code-dark .ace_marker-layer .ace_active-line {\ + background: #202020\ + }\ + .gf-code-dark .ace_gutter-active-line {\ + background-color: #272727\ + }\ + .gf-code-dark .ace_marker-layer .ace_selected-word {\ + border: 1px solid #49483e\ + }\ + .gf-code-dark .ace_invisible {\ + color: #52524d\ + }\ + .gf-code-dark .ace_entity.ace_name.ace_tag,\ + .gf-code-dark .ace_keyword,\ + .gf-code-dark .ace_meta.ace_tag,\ + .gf-code-dark .ace_storage {\ + color: #66d9ef\ + }\ + .gf-code-dark .ace_punctuation,\ + .gf-code-dark .ace_punctuation.ace_tag {\ + color: #fff\ + }\ + .gf-code-dark .ace_constant.ace_character,\ + .gf-code-dark .ace_constant.ace_language,\ + .gf-code-dark .ace_constant.ace_numeric,\ + .gf-code-dark .ace_constant.ace_other {\ + color: #fe85fc\ + }\ + .gf-code-dark .ace_invalid {\ + color: #f8f8f0;\ + background-color: #f92672\ + }\ + .gf-code-dark .ace_invalid.ace_deprecated {\ + color: #f8f8f0;\ + background-color: #ae81ff\ + }\ + .gf-code-dark .ace_support.ace_constant,\ + .gf-code-dark .ace_support.ace_function {\ + color: #59e6e3\ + }\ + .gf-code-dark .ace_fold {\ + background-color: #a6e22e;\ + border-color: #f8f8f2\ + }\ + .gf-code-dark .ace_storage.ace_type,\ + .gf-code-dark .ace_support.ace_class,\ + .gf-code-dark .ace_support.ace_type {\ + font-style: italic;\ + color: #66d9ef\ + }\ + .gf-code-dark .ace_entity.ace_name.ace_function,\ + .gf-code-dark .ace_entity.ace_other,\ + .gf-code-dark .ace_entity.ace_other.ace_attribute-name,\ + .gf-code-dark .ace_variable {\ + color: #a6e22e\ + }\ + .gf-code-dark .ace_variable.ace_parameter {\ + font-style: italic;\ + color: #fd971f\ + }\ + .gf-code-dark .ace_string {\ + color: #74e680\ + }\ + .gf-code-dark .ace_paren {\ + color: #f0a842\ + }\ + .gf-code-dark .ace_operator {\ + color: #FFF\ + }\ + .gf-code-dark .ace_comment {\ + color: #75715e\ + }\ + .gf-code-dark .ace_indent-guide {\ + background: url(data:image/png;base64,ivborw0kggoaaaansuheugaaaaeaaaaccayaaaczgbynaaaaekleqvqimwpq0fd0zxbzd/wpaajvaoxesgneaaaaaelftksuqmcc) right repeat-y\ + }"; + + var dom = require("../lib/dom"); + dom.importCssString(exports.cssText, exports.cssClass); +}); + +/* jshint ignore:end */ diff --git a/public/app/core/components/form_dropdown/form_dropdown.ts b/public/app/core/components/form_dropdown/form_dropdown.ts index 1c611e94397..6e3156df1fc 100644 --- a/public/app/core/components/form_dropdown/form_dropdown.ts +++ b/public/app/core/components/form_dropdown/form_dropdown.ts @@ -115,7 +115,9 @@ export class FormDropdownCtrl { this.optionCache = options; // extract texts - let optionTexts = _.map(options, 'text'); + let optionTexts = _.map(options, op => { + return _.escape(op.text); + }); // add custom values if (this.allowCustom) { diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 996a15bebef..fdb0e3540e5 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -19,6 +19,8 @@ import "./directives/diff-view"; import './jquery_extended'; import './partials'; import './components/jsontree/jsontree'; +import './components/code_editor/code_editor'; +import './utils/outline'; import {grafanaAppDirective} from './components/grafana_app'; import {sideMenuDirective} from './components/sidemenu/sidemenu'; diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index e2f7a5e9e91..b54887b4c2e 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -7,8 +7,9 @@ import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; export class BackendSrv { - inFlightRequests = {}; - HTTP_REQUEST_CANCELLED = -1; + private inFlightRequests = {}; + private HTTP_REQUEST_CANCELLED = -1; + private noBackendCache: boolean; /** @ngInject */ constructor(private $http, private alertSrv, private $rootScope, private $q, private $timeout, private contextSrv) { @@ -34,6 +35,13 @@ export class BackendSrv { return this.request({ method: 'PUT', url: url, data: data }); } + withNoBackendCache(callback) { + this.noBackendCache = true; + return callback().finally(() => { + this.noBackendCache = false; + }); + } + requestErrorHandler(err) { if (err.isHandled) { return; @@ -149,6 +157,10 @@ export class BackendSrv { options.headers['X-DS-Authorization'] = options.headers.Authorization; delete options.headers.Authorization; } + + if (this.noBackendCache) { + options.headers['X-Grafana-NoCache'] = 'true'; + } } return this.$http(options).then(response => { diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index f03ca8c8003..fb317fb7a68 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -180,14 +180,14 @@ export class KeybindingSrv { }); // collapse all rows - this.bind('d C', () => { + this.bind('d shift+c', () => { for (let row of dashboard.rows) { row.collapse = true; } }); // expand all rows - this.bind('d E', () => { + this.bind('d shift+e', () => { for (let row of dashboard.rows) { row.collapse = false; } diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index b8b7ef6ed26..fb58f618e91 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -191,10 +191,9 @@ export default class TimeSeries { this.stats.logmin = currentValue; } - } - - if (currentValue !== 0) { - this.allIsZero = false; + if (currentValue !== 0) { + this.allIsZero = false; + } } result.push([currentTime, currentValue]); diff --git a/public/app/core/utils/file_export.ts b/public/app/core/utils/file_export.ts index 960be37230c..96af397fadf 100644 --- a/public/app/core/utils/file_export.ts +++ b/public/app/core/utils/file_export.ts @@ -7,8 +7,8 @@ declare var window: any; const DEFAULT_DATETIME_FORMAT: String = 'YYYY-MM-DDTHH:mm:ssZ'; -export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT) { - var text = 'Series;Time;Value\n'; +export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { + var text = excel ? 'sep=;\n' : '' + 'Series;Time;Value\n'; _.each(seriesList, function(series) { _.each(series.datapoints, function(dp) { text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n'; @@ -17,8 +17,8 @@ export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATET saveSaveBlob(text, 'grafana_data_export.csv'); } -export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT) { - var text = 'Time;'; +export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { + var text = excel ? 'sep=;\n' : '' + 'Time;'; // add header _.each(seriesList, function(series) { text += series.alias + ';'; @@ -52,8 +52,8 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL saveSaveBlob(text, 'grafana_data_export.csv'); } -export function exportTableDataToCsv(table) { - var text = ''; +export function exportTableDataToCsv(table, excel = false) { + var text = excel ? 'sep=;\n' : ''; // add header _.each(table.columns, function(column) { text += (column.title || column.text) + ';'; diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index 6f8340d5940..37888fb10ac 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -163,21 +163,15 @@ function($, _) { ms: 0.001 }; - kbn.calculateInterval = function(range, resolution, userInterval) { + kbn.calculateInterval = function(range, resolution, lowLimitInterval) { var lowLimitMs = 1; // 1 millisecond default low limit - var intervalMs, lowLimitInterval; + var intervalMs; - if (userInterval) { - if (userInterval[0] === '>') { - lowLimitInterval = userInterval.slice(1); - lowLimitMs = kbn.interval_to_ms(lowLimitInterval); - } - else { - return { - intervalMs: kbn.interval_to_ms(userInterval), - interval: userInterval, - }; + if (lowLimitInterval) { + if (lowLimitInterval[0] === '>') { + lowLimitInterval = lowLimitInterval.slice(1); } + lowLimitMs = kbn.interval_to_ms(lowLimitInterval); } intervalMs = kbn.round_interval((range.to.valueOf() - range.from.valueOf()) / resolution); diff --git a/public/app/core/utils/outline.js b/public/app/core/utils/outline.js new file mode 100644 index 00000000000..f968b3e51f9 --- /dev/null +++ b/public/app/core/utils/outline.js @@ -0,0 +1,32 @@ +// outline.js +// based on http://www.paciellogroup.com/blog/2012/04/how-to-remove-css-outlines-in-an-accessible-manner/ +(function(d) { + "use strict"; + + var style_element = d.createElement('STYLE'), + dom_events = 'addEventListener' in d, + add_event_listener = function(type, callback) { + // Basic cross-browser event handling + if(dom_events){ + d.addEventListener(type, callback); + } else { + d.attachEvent('on' + type, callback); + } + }, + set_css = function(css_text) { + // Handle setting of