Просмотр исходного кода

Merge branch 'master' into develop-newgrid-row-design2

Torkel Ödegaard 8 лет назад
Родитель
Сommit
a38ded9e7e
100 измененных файлов с 2169 добавлено и 847 удалено
  1. 0 3
      .floo
  2. 0 12
      .flooignore
  3. 24 4
      CHANGELOG.md
  4. 2 2
      README.md
  5. 10 12
      ROADMAP.md
  6. 1 1
      docs/VERSION
  7. 1 0
      docs/sources/archive.md
  8. 2 2
      docs/sources/guides/getting_started.md
  9. 69 0
      docs/sources/guides/whats-new-in-v4-5.md
  10. 16 5
      docs/sources/http_api/admin.md
  11. 6 0
      docs/sources/installation/configuration.md
  12. 4 5
      docs/sources/installation/debian.md
  13. 1 0
      docs/sources/installation/rpm.md
  14. 2 2
      docs/sources/reference/templating.md
  15. 3 2
      package.json
  16. 1 0
      packaging/deb/control/postinst
  17. 3 0
      packaging/deb/default/grafana-server
  18. 7 5
      packaging/deb/systemd/grafana-server.service
  19. 4 4
      packaging/publish/publish_testing.sh
  20. 1 0
      packaging/rpm/control/postinst
  21. 3 0
      packaging/rpm/sysconfig/grafana-server
  22. 7 5
      packaging/rpm/systemd/grafana-server.service
  23. 3 3
      pkg/api/api.go
  24. 1 2
      pkg/api/app_routes.go
  25. 4 1
      pkg/api/avatar/avatar.go
  26. 11 15
      pkg/api/cloudwatch/metrics.go
  27. 22 159
      pkg/api/dataproxy.go
  28. 0 63
      pkg/api/dataproxy_test.go
  29. 5 1
      pkg/api/http_server.go
  30. 349 0
      pkg/api/pluginproxy/ds_proxy.go
  31. 165 0
      pkg/api/pluginproxy/ds_proxy_test.go
  32. 2 21
      pkg/api/pluginproxy/pluginproxy.go
  33. 4 3
      pkg/api/plugins.go
  34. 0 38
      pkg/cmd/grafana-server/main.go
  35. 46 2
      pkg/cmd/grafana-server/server.go
  36. 1 1
      pkg/components/dashdiffs/formatter_basic.go
  37. 3 3
      pkg/components/dashdiffs/formatter_json.go
  38. 2 2
      pkg/login/auth.go
  39. 3 0
      pkg/metrics/metrics.go
  40. 3 3
      pkg/middleware/auth_proxy.go
  41. 11 6
      pkg/plugins/app_plugin.go
  42. 22 7
      pkg/plugins/datasource_plugin.go
  43. 2 5
      pkg/plugins/models.go
  44. 9 14
      pkg/plugins/plugins.go
  45. 1 1
      pkg/services/alerting/conditions/query.go
  46. 1 1
      pkg/services/alerting/notifiers/hipchat.go
  47. 3 0
      pkg/services/sqlstore/datasource.go
  48. 1 1
      pkg/services/sqlstore/sqlstore.go
  49. 1 1
      pkg/services/sqlstore/sqlutil/sqlutil.go
  50. 5 1
      pkg/tsdb/influxdb/query.go
  51. 1 1
      pkg/tsdb/influxdb/query_test.go
  52. 1 1
      pkg/tsdb/mqe/types_test.go
  53. 7 3
      pkg/tsdb/mysql/mysql.go
  54. 209 0
      public/app/core/components/code_editor/code_editor.ts
  55. 513 0
      public/app/core/components/code_editor/mode-prometheus.js
  56. 21 0
      public/app/core/components/code_editor/snippets/prometheus.js
  57. 116 0
      public/app/core/components/code_editor/theme-grafana-dark.js
  58. 3 1
      public/app/core/components/form_dropdown/form_dropdown.ts
  59. 2 0
      public/app/core/core.ts
  60. 14 2
      public/app/core/services/backend_srv.ts
  61. 2 2
      public/app/core/services/keybindingSrv.ts
  62. 3 4
      public/app/core/time_series2.ts
  63. 6 6
      public/app/core/utils/file_export.ts
  64. 6 12
      public/app/core/utils/kbn.js
  65. 32 0
      public/app/core/utils/outline.js
  66. 11 7
      public/app/features/dashboard/export/export_modal.ts
  67. 6 2
      public/app/features/dashboard/export_data/export_data_modal.html
  68. 11 3
      public/app/features/dashboard/export_data/export_data_modal.ts
  69. 1 1
      public/app/features/dashboard/partials/shareModal.html
  70. 19 14
      public/app/features/org/org_users_ctrl.ts
  71. 2 2
      public/app/features/org/partials/orgUsers.html
  72. 38 1
      public/app/features/panel/metrics_tab.ts
  73. 0 9
      public/app/features/panel/panel_ctrl.ts
  74. 85 42
      public/app/features/panel/partials/metrics_tab.html
  75. 15 8
      public/app/features/panel/query_troubleshooter.ts
  76. 31 21
      public/app/features/plugins/ds_edit_ctrl.ts
  77. 6 2
      public/app/features/plugins/partials/ds_edit.html
  78. 4 5
      public/app/features/plugins/plugin_edit_ctrl.ts
  79. 1 1
      public/app/features/templating/interval_variable.ts
  80. 5 0
      public/app/headers/common.d.ts
  81. 9 8
      public/app/plugins/datasource/elasticsearch/partials/config.html
  82. 0 38
      public/app/plugins/datasource/elasticsearch/partials/query.options.html
  83. 5 1
      public/app/plugins/datasource/elasticsearch/plugin.json
  84. 10 4
      public/app/plugins/datasource/elasticsearch/query_builder.js
  85. 10 0
      public/app/plugins/datasource/elasticsearch/query_help.md
  86. 9 8
      public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts
  87. 1 0
      public/app/plugins/datasource/graphite/config_ctrl.ts
  88. 13 0
      public/app/plugins/datasource/graphite/datasource.ts
  89. 4 4
      public/app/plugins/datasource/graphite/gfunc.js
  90. 0 5
      public/app/plugins/datasource/graphite/module.ts
  91. 0 123
      public/app/plugins/datasource/graphite/partials/query.options.html
  92. 5 0
      public/app/plugins/datasource/graphite/plugin.json
  93. 30 0
      public/app/plugins/datasource/graphite/query_help.md
  94. 5 2
      public/app/plugins/datasource/influxdb/influx_query.ts
  95. 0 5
      public/app/plugins/datasource/influxdb/module.ts
  96. 9 5
      public/app/plugins/datasource/influxdb/partials/config.html
  97. 0 76
      public/app/plugins/datasource/influxdb/partials/query.options.html
  98. 4 0
      public/app/plugins/datasource/influxdb/plugin.json
  99. 28 0
      public/app/plugins/datasource/influxdb/query_help.md
  100. 9 0
      public/app/plugins/datasource/influxdb/query_part.ts

+ 0 - 3
.floo

@@ -1,3 +0,0 @@
-{
-  "url": "https://floobits.com/raintank/grafana"
-}

+ 0 - 12
.flooignore

@@ -1,12 +0,0 @@
-#*
-*.o
-*.pyc
-*.pyo
-*~
-extern/
-node_modules/
-tmp/
-data/
-vendor/
-public_gen/
-dist/

+ 24 - 4
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)

+ 2 - 2
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

+ 10 - 12
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)  

+ 1 - 1
docs/VERSION

@@ -1 +1 @@
-v4.2
+v4.3

+ 1 - 0
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)

+ 2 - 2
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. 
 
 <img class="no-shadow" src="/img/docs/v2/v2_top_nav_annotated.png">
 
@@ -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`.

+ 69 - 0
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)
+

+ 16 - 5
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

+ 6 - 0
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`

+ 4 - 5
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
 ```
 
-<!--
-## Install Beta
+## Install Latest Beta
 
 ```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.3.0-beta1_amd64.deb
+wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.0-beta1_amd64.deb
 sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_4.3.0-beta1_amd64.deb
+sudo dpkg -i grafana_4.5.0-beta1_amd64.deb
 ```
--->
 
 ## APT Repository
 

+ 1 - 0
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.

+ 2 - 2
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.
 

+ 3 - 2
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",

+ 1 - 0
packaging/deb/control/postinst

@@ -6,6 +6,7 @@ set -e
 
 IS_UPGRADE=false
 
+
 case "$1" in
 	configure)
 	[ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana"

+ 3 - 0
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

+ 7 - 5
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

+ 4 - 4
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
 

+ 1 - 0
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

+ 3 - 0
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

+ 7 - 5
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

+ 3 - 3
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() {

+ 1 - 2
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 != "" {

+ 4 - 1
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()

+ 11 - 15
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)
 

+ 22 - 159
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")
-)
-
-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
-
-		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)
-		}
+const HeaderNameNoBackendCache = "X-Grafana-NoCache"
 
-		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)
-		}
+func (hs *HttpServer) getDatasourceById(id int64, orgId int64, nocache bool) (*m.DataSource, error) {
+	cacheKey := fmt.Sprintf("ds-%d", id)
 
-		// 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()
 }

+ 0 - 63
pkg/api/dataproxy_test.go

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

+ 5 - 1
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),
 	}
 }
 

+ 349 - 0
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
+}

+ 165 - 0
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")
+		})
+
+	})
+}

+ 2 - 21
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 {

+ 4 - 3
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)
 	}

+ 0 - 38
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)

+ 46 - 2
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)
+}

+ 1 - 1
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

+ 3 - 3
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:

+ 2 - 2
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
 			}

+ 3 - 0
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")

+ 3 - 3
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
 				}
 			}

+ 11 - 6
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

+ 22 - 7
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
 }

+ 2 - 5
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 {

+ 9 - 14
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
 	}
 }

+ 1 - 1
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)

+ 1 - 1
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 + "<br><a href=" + ruleUrl + ">Check Dasboard</a>"
+	message := evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + "<br><a href=" + ruleUrl + ">Check Dashboard</a>"
 	fields := make([]map[string]interface{}, 0)
 	message += "<br>"
 	for index, evt := range evalContext.EvalMatches {

+ 3 - 0
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)
 

+ 1 - 1
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" {

+ 1 - 1
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) {

+ 5 - 1
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 "
 	}
 

+ 1 - 1
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() {

+ 1 - 1
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: ""},

+ 7 - 3
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)

+ 209 - 0
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:
+ * <code-editor content="ctrl.target.query" on-change="ctrl.panelCtrl.refresh()"
+ *  data-mode="sql" data-show-gutter>
+ * </code-editor>
+ *
+ * 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
+ */
+
+///<reference path="../../../headers/common.d.ts" />
+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 = `<div></div>`;
+
+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);

+ 513 - 0
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("&nbsp<br>");
+}
+
+function convertMarkDownTags(text) {
+  text = text.replace(/```(.+)```/, "<pre>$1</pre>");
+  text = text.replace(/`([^`]+)`/, "<code>$1</code>");
+  return text;
+}
+
+function convertToHTML(item) {
+  var docText = lang.escapeHTML(item.docText);
+  docText = convertMarkDownTags(wrapText(docText, 40));
+  return [
+    "<b>", lang.escapeHTML(item.def), "</b>", "<hr></hr>", docText, "<br>&nbsp"
+  ].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;
+
+});

+ 21 - 0
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";
+});

+ 116 - 0
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 */

+ 3 - 1
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) {

+ 2 - 0
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';

+ 14 - 2
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 => {

+ 2 - 2
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;
       }

+ 3 - 4
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]);

+ 6 - 6
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) + ';';

+ 6 - 12
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);

+ 32 - 0
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 <style> element contents in IE8
+      !!style_element.styleSheet ? style_element.styleSheet.cssText = css_text : style_element.innerHTML = css_text;
+    };
+
+  d.getElementsByTagName('HEAD')[0].appendChild(style_element);
+
+  // Using mousedown instead of mouseover, so that previously focused elements don't lose focus ring on mouse move
+  add_event_listener('mousedown', function() {
+    set_css(':focus{outline:0 !important}::-moz-focus-inner{border:0;}');
+  });
+
+  add_event_listener('keydown', function() {
+    set_css('');
+  });
+
+})(document);

+ 11 - 7
public/app/features/dashboard/export/export_modal.ts

@@ -12,13 +12,14 @@ import {DashboardExporter} from './exporter';
 export class DashExportCtrl {
   dash: any;
   exporter: DashboardExporter;
+  dismiss: () => void;
 
   /** @ngInject */
-  constructor(private backendSrv, dashboardSrv, datasourceSrv, $scope) {
+  constructor(private backendSrv, private dashboardSrv, datasourceSrv, private $scope) {
     this.exporter = new DashboardExporter(datasourceSrv);
 
-    this.exporter.makeExportable(dashboardSrv.getCurrent()).then(dash => {
-      $scope.$apply(() => {
+    this.exporter.makeExportable(this.dashboardSrv.getCurrent()).then(dash => {
+      this.$scope.$apply(() => {
         this.dash = dash;
       });
     });
@@ -31,11 +32,13 @@ export class DashExportCtrl {
   }
 
   saveJson() {
-    var html = angular.toJson(this.dash, true);
-    var uri = "data:application/json," + encodeURIComponent(html);
-    var newWindow = window.open(uri);
-  }
+    var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
 
+    this.$scope.$root.appEvent('show-json-editor', {
+      object: clone,
+    });
+    this.dismiss();
+  }
 }
 
 export function dashExportDirective() {
@@ -45,6 +48,7 @@ export function dashExportDirective() {
     controller: DashExportCtrl,
     bindToController: true,
     controllerAs: 'ctrl',
+    scope: {dismiss: "&"}
   };
 }
 

+ 6 - 2
public/app/features/dashboard/export_data/export_data_modal.html

@@ -11,17 +11,21 @@
 
   <div class="modal-content">
     <div class="p-t-2">
-      <div class="gf-form">
+      <div class="gf-form" ng-hide="ctrl.panel === 'table'">
         <label class="gf-form-label width-10">Mode</label>
         <div class="gf-form-select-wrapper">
           <select class="gf-form-input" ng-model="ctrl.asRows" ng-options="f.value as f.text for f in [{text: 'Series as rows', value: true}, {text: 'Series as columns', value: false}]">
           </select>
         </div>
       </div>
-      <div class="gf-form">
+      <div class="gf-form" ng-hide="ctrl.panel === 'table'">
         <label class="gf-form-label width-10">Date Time Format</label>
         <input type="text" class="gf-form-input" ng-model="ctrl.dateTimeFormat">
       </div>
+      <gf-form-switch class="gf-form"
+        label="Export To Excel" label-class="width-12" switch-class="max-width-6"
+        checked="ctrl.excel">
+      </gf-form-switch>
     </div>
 
     <div class="gf-form-button-row text-center">

+ 11 - 3
public/app/features/dashboard/export_data/export_data_modal.ts

@@ -6,17 +6,24 @@ import appEvents from 'app/core/app_events';
 
 export class ExportDataModalCtrl {
   private data: any[];
+  private panel: string;
   asRows: Boolean = true;
   dateTimeFormat: String = 'YYYY-MM-DDTHH:mm:ssZ';
+  excel: false;
   /** @ngInject */
   constructor(private $scope) { }
 
   export() {
-    if (this.asRows) {
-      fileExport.exportSeriesListToCsv(this.data, this.dateTimeFormat);
+    if (this.panel === 'table') {
+      fileExport.exportTableDataToCsv(this.data, this.excel);
     } else {
-      fileExport.exportSeriesListToCsvColumns(this.data, this.dateTimeFormat);
+      if (this.asRows) {
+        fileExport.exportSeriesListToCsv(this.data, this.dateTimeFormat, this.excel);
+      } else {
+        fileExport.exportSeriesListToCsvColumns(this.data, this.dateTimeFormat, this.excel);
+      }
     }
+
     this.dismiss();
   }
 
@@ -32,6 +39,7 @@ export function exportDataModal() {
     controller: ExportDataModalCtrl,
     controllerAs: 'ctrl',
     scope: {
+      panel: '<',
       data: '<' // The difference to '=' is that the bound properties are not watched
     },
     bindToController: true

+ 1 - 1
public/app/features/dashboard/partials/shareModal.html

@@ -47,7 +47,7 @@
 </script>
 
 <script type="text/ng-template" id="shareExport.html">
-	<dash-export-modal></dash-export-modal>
+	<dash-export-modal dismiss="dismiss()"></dash-export-modal>
 </script>
 
 <script type="text/ng-template" id="shareLinkOptions.html">

+ 19 - 14
public/app/features/org/org_users_ctrl.ts

@@ -91,20 +91,25 @@ export class OrgUsersCtrl {
     evt.stopPropagation();
   }
 
-  openAddUsersView() {
-    var modalScope = this.$scope.$new();
-    modalScope.invitesSent = this.get.bind(this);
-
-    var src = config.disableLoginForm
-      ? 'public/app/features/org/partials/add_user.html'
-      : 'public/app/features/org/partials/invite.html';
-
-    this.$scope.appEvent('show-modal', {
-      src: src,
-      modalClass: 'invite-modal',
-      scope: modalScope
-    });
-  }
+ getInviteUrl(invite) {
+   return invite.url;
+ }
+
+ openAddUsersView() {
+   var modalScope = this.$scope.$new();
+   modalScope.invitesSent = this.get.bind(this);
+
+   var src = config.disableLoginForm
+     ? 'public/app/features/org/partials/add_user.html'
+     : 'public/app/features/org/partials/invite.html';
+
+     this.$scope.appEvent('show-modal', {
+       src: src,
+       modalClass: 'invite-modal',
+       scope: modalScope
+     });
+ }
+
 }
 
 coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);

+ 2 - 2
public/app/features/org/partials/orgUsers.html

@@ -22,7 +22,7 @@
 						Users ({{ctrl.users.length}})
 					</a>
 				</li>
-				<li class="gf-tabs-item">
+				<li class="gf-tabs-item" ng-show="ctrl.pendingInvites.length">
 					<a class="gf-tabs-link" ng-click="ctrl.editor.index = 1" ng-class="{active: ctrl.editor.index === 1}">
 						Pending Invites ({{ctrl.pendingInvites.length}})
 					</a>
@@ -86,7 +86,7 @@
           <td>{{invite.email}}</td>
           <td>{{invite.name}}</td>
           <td class="text-right">
-            <button class="btn btn-inverse btn-mini " data-clipboard-text="{{invite.url}}" clipboard-button ng-click="ctrl.copyInviteToClipboard($event)">
+            <button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
               <i class="fa fa-clipboard"></i> Copy Invite
             </button>
             &nbsp;

+ 38 - 1
public/app/features/panel/metrics_tab.ts

@@ -2,6 +2,7 @@
 
 import _ from 'lodash';
 import {DashboardModel} from '../dashboard/model';
+import Remarkable from 'remarkable';
 
 export class MetricsTabCtrl {
   dsName: string;
@@ -13,9 +14,15 @@ export class MetricsTabCtrl {
   dashboard: DashboardModel;
   panelDsValue: any;
   addQueryDropdown: any;
+  queryTroubleshooterOpen: boolean;
+  helpOpen: boolean;
+  optionsOpen: boolean;
+  hasQueryHelp: boolean;
+  helpHtml: string;
+  queryOptions: any;
 
   /** @ngInject */
-  constructor($scope, private uiSegmentSrv, private datasourceSrv) {
+  constructor($scope, private $sce, private datasourceSrv, private backendSrv, private $timeout) {
     this.panelCtrl = $scope.ctrl;
     $scope.ctrl = this;
 
@@ -33,6 +40,12 @@ export class MetricsTabCtrl {
     this.addQueryDropdown = {text: 'Add Query', value: null, fake: true};
     // update next ref id
     this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
+    this.updateDatasourceOptions();
+  }
+
+  updateDatasourceOptions() {
+    this.hasQueryHelp = this.current.meta.hasQueryHelp;
+    this.queryOptions = this.current.meta.queryOptions;
   }
 
   getOptions(includeBuiltin) {
@@ -50,6 +63,7 @@ export class MetricsTabCtrl {
 
     this.current = option.datasource;
     this.panelCtrl.setDatasource(option.datasource);
+    this.updateDatasourceOptions();
   }
 
   addMixedQuery(option) {
@@ -65,6 +79,29 @@ export class MetricsTabCtrl {
   addQuery() {
     this.panelCtrl.addQuery({isNew: true});
   }
+
+  toggleHelp() {
+    this.optionsOpen = false;
+    this.queryTroubleshooterOpen = false;
+    this.helpOpen = !this.helpOpen;
+
+    this.backendSrv.get(`/api/plugins/${this.current.meta.id}/markdown/query_help`).then(res => {
+      var md = new Remarkable();
+      this.helpHtml = this.$sce.trustAsHtml(md.render(res));
+    });
+  }
+
+  toggleOptions() {
+    this.helpOpen = false;
+    this.queryTroubleshooterOpen = false;
+    this.optionsOpen = !this.optionsOpen;
+  }
+
+  toggleQueryTroubleshooter() {
+    this.helpOpen = false;
+    this.optionsOpen = false;
+    this.queryTroubleshooterOpen = !this.queryTroubleshooterOpen;
+  }
 }
 
 /** @ngInject **/

+ 0 - 9
public/app/features/panel/panel_ctrl.ts

@@ -29,7 +29,6 @@ export class PanelCtrl {
   fullscreen: boolean;
   inspector: any;
   editModeInitiated: boolean;
-  editorHelpIndex: number;
   editMode: any;
   height: any;
   containerHeight: any;
@@ -191,14 +190,6 @@ export class PanelCtrl {
     this.events.emit('render', payload);
   }
 
-  toggleEditorHelp(index) {
-    if (this.editorHelpIndex === index) {
-      this.editorHelpIndex = null;
-      return;
-    }
-    this.editorHelpIndex = index;
-  }
-
   duplicate() {
     this.dashboard.duplicatePanel(this.panel);
     this.$timeout(() => {

+ 85 - 42
public/app/features/panel/partials/metrics_tab.html

@@ -1,53 +1,96 @@
-<div class="query-editor-rows gf-form-group">
-  <div ng-repeat="target in ctrl.panel.targets" ng-class="{'gf-form-disabled': target.hide}">
-    <rebuild-on-change property="ctrl.panel.datasource || target.datasource" show-null="true">
-      <plugin-component type="query-ctrl">
-      </plugin-component>
-    </rebuild-on-change>
-	</div>
-
-	<div class="gf-form-query">
-		<div class="gf-form gf-form-query-letter-cell">
-			<label class="gf-form-label">
-				<span class="gf-form-query-letter-cell-carret">
-					<i class="fa fa-caret-down"></i>
-				</span>
-				<span class="gf-form-query-letter-cell-letter">{{ctrl.panelCtrl.nextRefId}}</span>
-			</label>
-      <button class="btn btn-secondary gf-form-btn" ng-click="ctrl.addQuery()" ng-hide="ctrl.current.meta.mixed">
-        Add Query
-      </button>
-
-      <div class="dropdown" ng-if="ctrl.current.meta.mixed">
-        <gf-form-dropdown model="ctrl.addQueryDropdown"
-                          get-options="ctrl.getOptions(false)"
-                          on-change="ctrl.addMixedQuery($option)">
-        </gf-form-dropdown>
-      </div>
-    </div>
-  </div>
-</div>
-
-<!-- <query&#45;troubleshooter panel&#45;ctrl="ctrl.panelCtrl"></query&#45;troubleshooter> -->
-
 <div class="gf-form-group">
   <div class="gf-form-inline">
     <div class="gf-form">
-      <label class="gf-form-label">Panel Data Source</label>
-      <gf-form-dropdown model="ctrl.panelDsValue"
+			<label class="gf-form-label gf-query-ds-label">
+				<i class="icon-gf icon-gf-datasources"></i>
+			</label>
+      <label class="gf-form-label">Data Source</label>
+
+      <gf-form-dropdown model="ctrl.panelDsValue" css-class="gf-size-auto"
                         lookup-text="true"
                         get-options="ctrl.getOptions(true)"
                         on-change="ctrl.datasourceChanged($option)">
       </gf-form-dropdown>
-    </div>
-  </div>
-</div>
+		</div>
+
+		<div class="gf-form gf-form--grow">
+			<label class="gf-form-label gf-form-label--grow"></label>
+		</div>
+		<div class="gf-form" ng-if="ctrl.queryOptions">
+			<a class="gf-form-label" ng-click="ctrl.toggleOptions()">
+				<i class="fa fa-fw fa-caret-right" ng-hide="ctrl.optionsOpen"></i><i class="fa fa-fw fa-caret-down" ng-show="ctrl.optionsOpen"></i>Options
+			</a>
+		</div>
+		<div class="gf-form" ng-if="ctrl.hasQueryHelp">
+			<button class="gf-form-label" ng-click="ctrl.toggleHelp()">
+				<i class="fa fa-fw fa-caret-right" ng-hide="ctrl.helpOpen"></i><i class="fa fa-fw fa-caret-down" ng-show="ctrl.helpOpen"></i>Help
+			</button>
+		</div>
+		<div class="gf-form">
+			<button class="gf-form-label" ng-click="ctrl.toggleQueryTroubleshooter()" bs-tooltip="'Display query request & response'">
+				<i class="fa fa-fw fa-caret-right" ng-hide="ctrl.queryTroubleshooterOpen"></i><i class="fa fa-fw fa-caret-down" ng-show="ctrl.queryTroubleshooterOpen"></i>Query Inspector
+			</button>
+		</div>
+	</div>
 
-<rebuild-on-change property="ctrl.panel.datasource" show-null="true">
-  <plugin-component type="query-options-ctrl">
-  </plugin-component>
-</rebuild-on-change>
+	<div>
+		<div ng-if="ctrl.optionsOpen">
+			<div class="gf-form gf-form--flex-end" ng-if="ctrl.queryOptions.minInterval">
+				<label class="gf-form-label">Min time interval</label>
+				<input type="text" class="gf-form-input width-6" placeholder="{{ctrl.panelCtrl.interval}}" ng-model="ctrl.panel.interval" spellcheck="false" ng-model-onblur ng-change="ctrl.panelCtrl.refresh()" />
+				<info-popover mode="right-absolute">
+					A lower limit for the auto group by time interval. Recommended to be set to write frequency,
+					for example <code>1m</code> if your data is written every minute. Access auto interval via variable <code>$__interval</code> for time range
+					string and <code>$__interval_ms</code> for numeric variable that can be used in math expressions.
+				</info-popover>
+			</div>
+			<div class="gf-form gf-form--flex-end" ng-if="ctrl.queryOptions.cacheTimeout">
+				<label class="gf-form-label width-9">Cache timeout</label>
+				<input  type="text" class="gf-form-input width-6" placeholder="60" ng-model="ctrl.panel.cacheTimeout" ng-model-onblur ng-change="ctrl.panelCtrl.refresh()" spellcheck="false" />
+				<info-popover mode="right-absolute">
+					If your time series store has a query cache this option can override the default
+					cache timeout. Specify a numeric value in seconds.
+				</info-popover>
+			</div>
+			<div class="gf-form gf-form--flex-end" ng-if="ctrl.queryOptions.maxDataPoints">
+				<label class="gf-form-label width-9">Max data points</label>
+				<input type="text" class="gf-form-input width-6" placeholder="auto" ng-model-onblur ng-change="ctrl.panelCtrl.refresh()" ng-model="ctrl.panel.maxDataPoints" spellcheck="false"  />
+				<info-popover mode="right-absolute">
+					The maximum data points the query should return. For graphs this
+					is automatically set to one data point per pixel.
+				</info-popover>
+			</div>
+		</div>
 
-<div class="clearfix"></div>
+		<div class="grafana-info-box" ng-if="ctrl.helpOpen">
+			<div class="markdown-html" ng-bind-html="ctrl.helpHtml"></div>
+			<a class="grafana-info-box__close" ng-click="ctrl.toggleHelp()">
+				<i class="fa fa-chevron-up"></i>
+			</a>
+		</div>
 
+		<query-troubleshooter panel-ctrl="ctrl.panelCtrl" is-open="ctrl.queryTroubleshooterOpen"></query-troubleshooter>
+	</div>
 </div>
+
+<div class="query-editor-rows gf-form-group">
+	<div ng-repeat="target in ctrl.panel.targets" ng-class="{'gf-form-disabled': target.hide}">
+		<rebuild-on-change property="ctrl.panel.datasource || target.datasource" show-null="true">
+			<plugin-component type="query-ctrl">
+			</plugin-component>
+		</rebuild-on-change>
+	</div>
+
+	<div class="gf-form-query">
+		<div class="gf-form gf-form-query-letter-cell">
+			<label class="gf-form-label">
+				<span class="gf-form-query-letter-cell-carret">
+					<i class="fa fa-caret-down"></i>
+				</span>
+				<span class="gf-form-query-letter-cell-letter">{{ctrl.panelCtrl.nextRefId}}</span>
+			</label>
+			<button class="btn btn-secondary gf-form-btn" ng-click="ctrl.addQuery()" ng-hide="ctrl.current.meta.mixed">
+				Add Query
+			</button>
+		</div>
+	</div>

+ 15 - 8
public/app/features/panel/query_troubleshooter.ts

@@ -5,9 +5,8 @@ import appEvents  from 'app/core/app_events';
 import {coreModule, JsonExplorer} from 'app/core/core';
 
 const template = `
-<collapse-box title="Query Troubleshooter" is-open="ctrl.isOpen" state-changed="ctrl.stateChanged()"
-              ng-class="{'collapse-box--error': ctrl.hasError}">
-  <collapse-box-actions>
+<div class="query-troubleshooter" ng-if="ctrl.isOpen">
+  <div class="query-troubleshooter__header">
     <a class="pointer" ng-click="ctrl.toggleExpand()" ng-hide="ctrl.allNodesExpanded">
       <i class="fa fa-plus-square-o"></i> Expand All
     </a>
@@ -15,12 +14,12 @@ const template = `
       <i class="fa fa-minus-square-o"></i> Collapse All
     </a>
     <a class="pointer" clipboard-button="ctrl.getClipboardText()"><i class="fa fa-clipboard"></i> Copy to Clipboard</a>
-  </collapse-box-actions>
-  <collapse-box-body>
+  </div>
+  <div class="query-troubleshooter__body">
     <i class="fa fa-spinner fa-spin" ng-show="ctrl.isLoading"></i>
     <div class="query-troubleshooter-json"></div>
-  </collapse-box-body>
-</collapse-box>
+  </div>
+</div>
 `;
 
 export class QueryTroubleshooterCtrl {
@@ -42,7 +41,9 @@ export class QueryTroubleshooterCtrl {
 
     appEvents.on('ds-request-response', this.onRequestResponseEventListener);
     appEvents.on('ds-request-error', this.onRequestErrorEventListener);
+
     $scope.$on('$destroy',  this.removeEventsListeners.bind(this));
+    $scope.$watch('ctrl.isOpen',  this.stateChanged.bind(this));
   }
 
   removeEventsListeners() {
@@ -51,6 +52,11 @@ export class QueryTroubleshooterCtrl {
   }
 
   onRequestError(err) {
+    // ignore if closed
+    if (!this.isOpen) {
+      return;
+    }
+
     this.isOpen = true;
     this.hasError = true;
     this.onRequestResponse(err);
@@ -133,7 +139,8 @@ export function queryTroubleshooter() {
     bindToController: true,
     controllerAs: 'ctrl',
     scope: {
-      panelCtrl: "="
+      panelCtrl: "=",
+      isOpen: "=",
     },
     link: function(scope, elem, attrs, ctrl) {
 

+ 31 - 21
public/app/features/plugins/ds_edit_ctrl.ts

@@ -58,7 +58,7 @@ export class DataSourceEditCtrl {
 
   initNewDatasourceModel() {
     this.isNew = true;
-    this.current = angular.copy(defaults);
+    this.current = _.cloneDeep(defaults);
 
     // add to nav & breadcrumbs
     this.navModel.node = {text: 'New data source', icon: 'icon-gf icon-gf-fw icon-gf-datasources'};
@@ -101,11 +101,21 @@ export class DataSourceEditCtrl {
     });
   }
 
+  userChangedType() {
+    // reset model but keep name & default flag
+    this.current = _.defaults({
+      id: this.current.id,
+      name: this.current.name,
+      isDefault: this.current.isDefault,
+      type: this.current.type,
+    }, _.cloneDeep(defaults));
+    this.typeChanged();
+  }
+
   typeChanged() {
     this.hasDashboards = false;
     return this.backendSrv.get('/api/plugins/' + this.current.type + '/settings').then(pluginInfo => {
       this.datasourceMeta = pluginInfo;
-      console.log(this.datasourceMeta) ;
       this.hasDashboards = _.find(pluginInfo.includes, {type: 'dashboard'});
     });
   }
@@ -119,31 +129,31 @@ export class DataSourceEditCtrl {
   }
 
   testDatasource() {
-    this.testing = { done: false };
-
     this.datasourceSrv.get(this.current.name).then(datasource => {
       if (!datasource.testDatasource) {
-        delete this.testing;
         return;
       }
 
-      return datasource.testDatasource().then(result => {
-        this.testing.message = result.message;
-        this.testing.status = result.status;
-        this.testing.title = result.title;
-      }).catch(err => {
-        if (err.statusText) {
-          this.testing.message = err.statusText;
-          this.testing.title = "HTTP Error";
-        } else {
-          this.testing.message = err.message;
-          this.testing.title = "Unknown error";
-        }
-      });
-    }).finally(() => {
-      if (this.testing) {
+      this.testing = {done: false};
+
+      // make test call in no backend cache context
+      this.backendSrv.withNoBackendCache(() => {
+        return datasource.testDatasource().then(result => {
+          this.testing.message = result.message;
+          this.testing.status = result.status;
+          this.testing.title = result.title;
+        }).catch(err => {
+          if (err.statusText) {
+            this.testing.message = err.statusText;
+            this.testing.title = "HTTP Error";
+          } else {
+            this.testing.message = err.message;
+            this.testing.title = "Unknown error";
+          }
+        });
+      }).finally(() => {
         this.testing.done = true;
-      }
+      });
     });
   }
 

+ 6 - 2
public/app/features/plugins/partials/ds_edit.html

@@ -42,7 +42,7 @@
 						<div class="gf-form">
 							<span class="gf-form-label width-7">Type</span>
 							<div class="gf-form-select-wrapper max-width-23">
-								<select class="gf-form-input" ng-model="ctrl.current.type" ng-options="v.id as v.name for v in ctrl.types" ng-change="ctrl.typeChanged()"></select>
+								<select class="gf-form-input" ng-model="ctrl.current.type" ng-options="v.id as v.name for v in ctrl.types" ng-change="ctrl.userChangedType()"></select>
 							</div>
 						</div>
 					</div>
@@ -57,7 +57,9 @@
 						</plugin-component>
 					</rebuild-on-change>
 
-					<div ng-if="ctrl.testing" class="gf-form-group">
+					<br />
+
+					<div ng-if="ctrl.testing">
 						<h5 ng-show="!ctrl.testing.done">Testing.... <i class="fa fa-spiner fa-spin"></i></h5>
 						<div class="alert-{{ctrl.testing.status}} alert">
 							<div class="alert-title">{{ctrl.testing.title}}</div>
@@ -65,6 +67,8 @@
 						</div>
 					</div>
 
+					<br />
+
 					<div class="gf-form-button-row">
 						<button type="submit" class="btn btn-success" ng-click="ctrl.saveChanges()">Save</button>
 						<button type="submit" class="btn btn-danger" ng-show="!ctrl.isNew" ng-click="ctrl.delete()">

+ 4 - 5
public/app/features/plugins/plugin_edit_ctrl.ts

@@ -3,6 +3,7 @@
 import angular from 'angular';
 import _ from 'lodash';
 import appEvents from 'app/core/app_events';
+import Remarkable from 'remarkable';
 
 export class PluginEditCtrl {
   model: any;
@@ -68,11 +69,9 @@ export class PluginEditCtrl {
   }
 
   initReadme() {
-    return this.backendSrv.get(`/api/plugins/${this.pluginId}/readme`).then(res => {
-      return System.import('remarkable').then(Remarkable => {
-        var md = new Remarkable();
-        this.readmeHtml = this.$sce.trustAsHtml(md.render(res));
-      });
+    return this.backendSrv.get(`/api/plugins/${this.pluginId}/markdown/readme`).then(res => {
+      var md = new Remarkable();
+      this.readmeHtml = this.$sce.trustAsHtml(md.render(res));
     });
   }
 

+ 1 - 1
public/app/features/templating/interval_variable.ts

@@ -54,7 +54,7 @@ export class IntervalVariable implements Variable {
       this.options.unshift({ text: 'auto', value: '$__auto_interval' });
     }
 
-    var res = kbn.calculateInterval(this.timeSrv.timeRange(), this.auto_count, (this.auto_min ? ">"+this.auto_min : null));
+    var res = kbn.calculateInterval(this.timeSrv.timeRange(), this.auto_count, this.auto_min);
     this.templateSrv.setGrafanaVariable('$__auto_interval', res.interval);
   }
 

+ 5 - 0
public/app/headers/common.d.ts

@@ -82,3 +82,8 @@ declare module 'gemini-scrollbar' {
   var d3: any;
   export default d3;
 }
+
+declare module 'ace' {
+  var ace: any;
+  export default ace;
+}

+ 9 - 8
public/app/plugins/datasource/elasticsearch/partials/config.html

@@ -25,13 +25,14 @@
 		<span class="gf-form-label width-9">Version</span>
 		<select class="gf-form-input gf-size-auto" ng-model="ctrl.current.jsonData.esVersion" ng-options="f.value as f.name for f in ctrl.esVersions"></select>
 	</div>
-
-</div>
-
-<h3 class="page-heading">Default query settings</h3>
-<div class="gf-form-group">
-	<div class="gf-form">
-		<span class="gf-form-label">Group by time interval</span>
-		<input class="gf-form-input max-width-9" type="text" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="example: >10s">
+	<div class="gf-form-inline">
+		<div class="gf-form">
+			<span class="gf-form-label width-9">Min interval</span>
+			<input type="text" class="gf-form-input width-6" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="10s"></input>
+			<info-popover mode="right-absolute">
+				A lower limit for the auto group by time interval. Recommended to be set to write frequency,
+				for example <code>1m</code> if your data is written every minute.
+			</info-popover>
+		</div>
 	</div>
 </div>

+ 0 - 38
public/app/plugins/datasource/elasticsearch/partials/query.options.html

@@ -1,38 +0,0 @@
-<section class="grafana-metric-options">
-	<div class="gf-form-group">
-		<div class="gf-form">
-			<span class="gf-form-label">
-				<i class="fa fa-wrench"></i>
-			</span>
-			<span class="gf-form-label">Group by time interval</span>
-
-			<input type="text" class="gf-form-input max-width-10" ng-model="ctrl.panelCtrl.panel.interval" ng-blur="ctrl.panelCtrl.refresh();"
-							 spellcheck='false' placeholder="example: >10s">
-
-			<span class="gf-form-label">
-				<i class="fa fa-question-circle" bs-tooltip="'Set a low limit by having a greater sign: example: >60s'" data-placement="right"></i>
-			</span>
-		</div>
-			<div class="gf-form">
-				<span class="gf-form-label">
-					<i class="fa fa-info-circle"></i>
-				</span>
-				<span class="gf-form-label width-23">
-					<a ng-click="ctrl.panelCtrl.toggleEditorHelp(1);" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-						alias patterns
-					</a>
-				</span>
-		</div>
-	</div>
-</section>
-
-<div class="pull-left">
-	<div class="grafana-info-box" style="border: 0;"  ng-if="ctrl.panelCtrl.editorHelpIndex === 1">
-		<h5>Alias patterns</h5>
-		<ul ng-non-bindable>
-			<li>{{term fieldname}} = replaced with value of term group by</li>
-			<li>{{metric}} = replaced with metric name (ex. Average, Min, Max)</li>
-			<li>{{field}} = replaced with the metric field name</li>
-		</ul>
-	</div>
-</div>

+ 5 - 1
public/app/plugins/datasource/elasticsearch/plugin.json

@@ -21,5 +21,9 @@
   },
 
   "annotations": true,
-  "metrics": true
+  "metrics": true,
+
+  "queryOptions": {
+    "minInterval": true
+  }
 }

+ 10 - 4
public/app/plugins/datasource/elasticsearch/query_builder.js

@@ -1,5 +1,5 @@
 define([
-  './query_def'
+  './query_def',
 ],
 function (queryDef) {
   'use strict';
@@ -133,17 +133,23 @@ function (queryDef) {
       return;
     }
 
-    var i, filter, condition;
+    var i, filter, condition, queryCondition;
+
     for (i = 0; i < adhocFilters.length; i++) {
       filter = adhocFilters[i];
       condition = {};
       condition[filter.key] = filter.value;
+      queryCondition = {};
+      queryCondition[filter.key] = {query: filter.value};
+
       switch(filter.operator){
         case "=":
-          query.query.bool.filter.push({"term": condition});
+          if (!query.query.bool.must) { query.query.bool.must = []; }
+          query.query.bool.must.push({match_phrase: queryCondition});
           break;
         case "!=":
-          query.query.bool.filter.push({"bool": {"must_not": {"term": condition}}});
+          if (!query.query.bool.must_not) { query.query.bool.must_not = []; }
+          query.query.bool.must_not.push({match_phrase: queryCondition});
           break;
         case "<":
           condition[filter.key] = {"lt": filter.value};

+ 10 - 0
public/app/plugins/datasource/elasticsearch/query_help.md

@@ -0,0 +1,10 @@
+#### Alias patterns
+- {{term fieldname}} = replaced with value of term group by
+- {{metric}} = replaced with metric name (ex. Average, Min, Max)
+- {{field}} = replaced with the metric field name
+
+#### Documentation links
+
+[Grafana's Elasticsearch Documentation](http://docs.grafana.org/features/datasources/elasticsearch)
+
+[Official Elasticsearch Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html)

+ 9 - 8
public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts

@@ -40,8 +40,7 @@ describe('ElasticQueryBuilder', function() {
     var query = builder.build({
       metrics: [{type: 'count', id: '1'}],
       timeField: '@timestamp',
-      bucketAggs: [
-        {type: 'terms', field: '@host', id: '2'},
+      bucketAggs: [ {type: 'terms', field: '@host', id: '2'},
         {type: 'date_histogram', field: '@timestamp', id: '3'}
       ],
     });
@@ -282,6 +281,7 @@ describe('ElasticQueryBuilder', function() {
       bucketAggs: [{type: 'date_histogram', field: '@timestamp', id: '3'}],
     }, [
       {key: 'key1', operator: '=', value: 'value1'},
+      {key: 'key2', operator: '=', value: 'value2'},
       {key: 'key2', operator: '!=', value: 'value2'},
       {key: 'key3', operator: '<', value: 'value3'},
       {key: 'key4', operator: '>', value: 'value4'},
@@ -289,11 +289,12 @@ describe('ElasticQueryBuilder', function() {
       {key: 'key6', operator: '!~', value: 'value6'},
     ]);
 
-    expect(query.query.bool.filter[2].term["key1"]).to.be("value1");
-    expect(query.query.bool.filter[3].bool.must_not.term["key2"]).to.be("value2");
-    expect(query.query.bool.filter[4].range["key3"].lt).to.be("value3");
-    expect(query.query.bool.filter[5].range["key4"].gt).to.be("value4");
-    expect(query.query.bool.filter[6].regexp["key5"]).to.be("value5");
-    expect(query.query.bool.filter[7].bool.must_not.regexp["key6"]).to.be("value6");
+    expect(query.query.bool.must[0].match_phrase["key1"].query).to.be("value1");
+    expect(query.query.bool.must[1].match_phrase["key2"].query).to.be("value2");
+    expect(query.query.bool.must_not[0].match_phrase["key2"].query).to.be("value2");
+    expect(query.query.bool.filter[2].range["key3"].lt).to.be("value3");
+    expect(query.query.bool.filter[3].range["key4"].gt).to.be("value4");
+    expect(query.query.bool.filter[4].regexp["key5"]).to.be("value5");
+    expect(query.query.bool.filter[5].bool.must_not.regexp["key6"]).to.be("value6");
   });
 });

+ 1 - 0
public/app/plugins/datasource/graphite/config_ctrl.ts

@@ -9,6 +9,7 @@ export class GraphiteConfigCtrl {
 
   /** @ngInject */
   constructor($scope) {
+    this.current.jsonData = this.current.jsonData || {};
     this.current.jsonData.graphiteVersion = this.current.jsonData.graphiteVersion || '0.9';
   }
 

+ 13 - 0
public/app/plugins/datasource/graphite/datasource.ts

@@ -16,6 +16,19 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
   this.withCredentials = instanceSettings.withCredentials;
   this.render_method = instanceSettings.render_method || 'POST';
 
+  this.getQueryOptionsInfo = function() {
+    return {
+      "maxDataPoints": true,
+      "cacheTimeout": true,
+      "links": [
+        {
+          text: "Help",
+          url: "http://docs.grafana.org/features/datasources/graphite/#using-graphite-in-grafana"
+        }
+      ]
+    };
+  };
+
   this.query = function(options) {
     var graphOptions = {
       from: this.translateTime(options.rangeRaw.from, false),

+ 4 - 4
public/app/plugins/datasource/graphite/gfunc.js

@@ -139,8 +139,8 @@ function (_, $) {
   addFuncDef({
     name: 'percentileOfSeries',
     category: categories.Combine,
-    params: [{ name: "n", type: "int" }, { name: "interpolate", type: "select", options: ["true", "false"] }],
-    defaultParams: [95, "false"]
+    params: [{ name: 'n', type: 'int' }, { name: 'interpolate', type: 'boolean', options: ['true', 'false'] }],
+    defaultParams: [95, 'false']
   });
 
   addFuncDef({
@@ -261,8 +261,8 @@ function (_, $) {
   addFuncDef({
     name: 'sortByName',
     category: categories.Special,
-    params: [{ name: "natural", type: "select", options: ["true", "false"], optional: true }],
-    defaultParams: ["false"]
+    params: [{ name: 'natural', type: 'boolean', options: ['true', 'false'], optional: true }],
+    defaultParams: ['false']
   });
 
   addFuncDef({

+ 0 - 5
public/app/plugins/datasource/graphite/module.ts

@@ -2,10 +2,6 @@ import {GraphiteDatasource} from './datasource';
 import {GraphiteQueryCtrl} from './query_ctrl';
 import {GraphiteConfigCtrl} from './config_ctrl';
 
-class GraphiteQueryOptionsCtrl {
-  static templateUrl = 'partials/query.options.html';
-}
-
 class AnnotationsQueryCtrl {
   static templateUrl = 'partials/annotations.editor.html';
 }
@@ -14,7 +10,6 @@ export {
   GraphiteDatasource as Datasource,
   GraphiteQueryCtrl as QueryCtrl,
   GraphiteConfigCtrl as ConfigCtrl,
-  GraphiteQueryOptionsCtrl as QueryOptionsCtrl,
   AnnotationsQueryCtrl as AnnotationsQueryCtrl,
 };
 

+ 0 - 123
public/app/plugins/datasource/graphite/partials/query.options.html

@@ -1,123 +0,0 @@
-<section class="gf-form-group">
-	<div class="gf-form-inline">
-		<div class="gf-form max-width-15">
-			<span class="gf-form-label width-8">
-				Cache timeout
-			</span>
-			<input type="text"
-				class="gf-form-input"
-				ng-model="ctrl.panelCtrl.panel.cacheTimeout"
-				bs-tooltip="'Graphite parameter to override memcache default timeout (unit is seconds)'"
-				data-placement="right"
-				spellcheck='false'
-				placeholder="60">
-			</input>
-		</div>
-		<div class="gf-form max-width-15">
-			<span class="gf-form-label">Max data points</span>
-			<input type="text"
-				class="gf-form-input"
-				ng-model="ctrl.panelCtrl.panel.maxDataPoints"
-				bs-tooltip="'Override max data points, automatically set to graph width in pixels.'"
-				data-placement="right"
-				ng-model-onblur ng-change="ctrl.panelCtrl.refresh()"
-				spellcheck='false'
-				placeholder="auto">
-			</input>
-		</div>
-	</div>
-	<div class="gf-form-inline">
-		<div class="gf-form">
-			<span class="gf-form-label width-12">
-				<i class="fa fa-info-circle"></i>
-				<a ng-click="ctrl.panelCtrl.toggleEditorHelp(1);" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					Shorter legend names
-				</a>
-			</span>
-			<span class="gf-form-label width-12">
-				<i class="fa fa-info-circle"></i>
-				<a ng-click="ctrl.panelCtrl.toggleEditorHelp(2);" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					Series as parameters
-				</a>
-			</span>
-			<span class="gf-form-label width-7">
-				<i class="fa fa-info-circle"></i>
-				<a ng-click="ctrl.panelCtrl.toggleEditorHelp(3)" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					Stacking
-				</a>
-			</span>
-			<span class="gf-form-label width-8">
-				<i class="fa fa-info-circle"></i>
-				<a ng-click="ctrl.panelCtrl.toggleEditorHelp(4)" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					Templating
-				</a>
-			</span>
-			<span class="gf-form-label width-10">
-				<i class="fa fa-info-circle"></i>
-				<a ng-click="ctrl.panelCtrl.toggleEditorHelp(5)" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					max data points
-				</a>
-			</span>
-		</div>
-	</div>
-</section>
-
-<div class="editor-row">
-	<div class="grafana-info-box span8" ng-if="ctrl.panelCtrl.editorHelpIndex === 1">
-		<h5>Shorter legend names</h5>
-		<ul>
-			<li>alias() function to specify a custom series name</li>
-			<li>aliasByNode(2) to alias by a specific part of your metric path</li>
-			<li>aliasByNode(2, -1) you can add multiple segment paths, and use negative index</li>
-			<li>groupByNode(2, 'sum') is useful if you have 2 wildcards in your metric path and want to sumSeries and group by</li>
-		</ul>
-	</div>
-
-	<div class="grafana-info-box span8" ng-if="ctrl.panelCtrl.editorHelpIndex === 2">
-		<h5>Series as parameter</h5>
-		<ul>
-			<li>Some graphite functions allow you to have many series arguments</li>
-			<li>Use #[A-Z] to use a graphite query as parameter to a function</li>
-			<li>
-				Examples:
-				<ul>
-					<li>asPercent(#A, #B)</li>
-					<li>prod.srv-01.counters.count - asPercent(#A) : percentage of count in comparison with A query</li>
-					<li>prod.srv-01.counters.count - sumSeries(#A) : sum count and series A </li>
-					<li>divideSeries(#A, #B)</li>
-				</ul>
-			</li>
-			<li>If a query is added only to be used as a parameter, hide it from the graph with the eye icon</li>
-		</ul>
-	</div>
-
-	<div class="grafana-info-box span6" ng-if="ctrl.panelCtrl.editorHelpIndex === 3">
-		<h5>Stacking</h5>
-		<ul>
-			<li>You find the stacking option under Display Styles tab</li>
-			<li>When stacking is enabled make sure null point mode is set to 'null as zero'</li>
-		</ul>
-	</div>
-
-	<div class="grafana-info-box span6" ng-if="ctrl.panelCtrl.editorHelpIndex === 4">
-		<h5>Templating</h5>
-		<ul>
-			<li>You can use a template variable in place of metric names</li>
-			<li>You can use a template variable in place of function parameters</li>
-			<li>You enable the templating feature in Dashboard settings / Feature toggles </li>
-		</ul>
-	</div>
-
-	<div class="grafana-info-box span6" ng-if="ctrl.panelCtrl.editorHelpIndex === 5">
-		<h5>Max data points</h5>
-		<ul>
-			<li>Every graphite request is issued with a maxDataPoints parameter</li>
-			<li>Graphite uses this parameter to consolidate the real number of values down to this number</li>
-			<li>If there are more real values, then by default they will be consolidated using averages</li>
-			<li>This could hide real peaks and max values in your series</li>
-			<li>You can change how point consolidation is made using the consolidateBy graphite function</li>
-			<li>Point consolidation will effect series legend values (min,max,total,current)</li>
-			<li>If you override maxDataPoint and set a high value performance can be severely effected</li>
-		</ul>
-	</div>
-</div>

+ 5 - 0
public/app/plugins/datasource/graphite/plugin.json

@@ -11,6 +11,11 @@
   "alerting": true,
   "annotations": true,
 
+  "queryOptions": {
+    "maxDataPoints": true,
+    "cacheTimeout": true
+  },
+
   "info": {
     "author": {
       "name": "Grafana Project",

+ 30 - 0
public/app/plugins/datasource/graphite/query_help.md

@@ -0,0 +1,30 @@
+#### Get Shorter legend names
+
+- alias() function to specify a custom series name
+- aliasByNode(2) to alias by a specific part of your metric path
+- groupByNode(2, 'sum') is useful if you have 2 wildcards in your metric path and want to sumSeries and group by.
+
+#### Series as parameter
+
+- Some graphite functions allow you to have many series arguments
+- Use #[A-Z] to use a graphite query as parameter to a function
+- Examples:
+  - asPercent(#A, #B)
+  - divideSeries(#A, #B)
+
+If a query is added only to be used as a parameter, hide it from the graph with the eye icon
+
+#### Max data points
+- Every graphite request is issued with a maxDataPoints parameter
+- Graphite uses this parameter to consolidate the real number of values down to this number
+- If there are more real values, then by default they will be consolidated using averages
+- This could hide real peaks and max values in your series
+- You can change how point consolidation is made using the consolidateBy graphite function
+- Point consolidation will effect series legend values (min,max,total,current)
+- if you override maxDataPoint and set a high value performance can be severely effected
+
+#### Documentation links:
+
+[Grafana's Graphite Documentation](http://docs.grafana.org/features/datasources/graphite)
+
+[Official Graphite Documentation](https://graphite.readthedocs.io)

+ 5 - 2
public/app/plugins/datasource/influxdb/influx_query.ts

@@ -229,8 +229,11 @@ export default class InfluxQuery {
       return this.renderTagCondition(tag, index, interpolate);
     });
 
-    query += conditions.join(' ');
-    query += (conditions.length > 0 ? ' AND ' : '') + '$timeFilter';
+    if (conditions.length > 0) {
+      query += '(' + conditions.join(' ') + ') AND ';
+    }
+
+    query += '$timeFilter';
 
     var groupBySection = "";
     for (i = 0; i < this.groupByParts.length; i++) {

+ 0 - 5
public/app/plugins/datasource/influxdb/module.ts

@@ -5,10 +5,6 @@ class InfluxConfigCtrl {
   static templateUrl = 'partials/config.html';
 }
 
-class InfluxQueryOptionsCtrl {
-  static templateUrl = 'partials/query.options.html';
-}
-
 class InfluxAnnotationsQueryCtrl {
   static templateUrl = 'partials/annotations.editor.html';
 }
@@ -17,7 +13,6 @@ export {
   InfluxDatasource as Datasource,
   InfluxQueryCtrl as QueryCtrl,
   InfluxConfigCtrl as ConfigCtrl,
-  InfluxQueryOptionsCtrl as QueryOptionsCtrl,
   InfluxAnnotationsQueryCtrl as AnnotationsQueryCtrl,
 };
 

+ 9 - 5
public/app/plugins/datasource/influxdb/partials/config.html

@@ -24,10 +24,14 @@
 </div>
 
 <div class="gf-form-group">
-	<div class="gf-form max-width-21">
-		<span class="gf-form-label">Default group by time</span>
-		<input type="text" class="gf-form-input width-6" ng-model="ctrl.current.jsonData.timeInterval"
-		spellcheck='false' placeholder="example: >10s"></input>
-		<i class="fa fa-question-circle" bs-tooltip="'Set a low limit by having a greater sign: example: >10s'" data-placement="right"></i>
+	<div class="gf-form-inline">
+		<div class="gf-form">
+			<span class="gf-form-label">Min time interval</span>
+			<input type="text" class="gf-form-input width-6" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="10s"></input>
+			<info-popover mode="right-absolute">
+				A lower limit for the auto group by time interval. Recommended to be set to write frequency,
+				for example <code>1m</code> if your data is written every minute.
+			</info-popover>
+		</div>
 	</div>
 </div>

+ 0 - 76
public/app/plugins/datasource/influxdb/partials/query.options.html

@@ -1,76 +0,0 @@
-<section class="grafana-metric-options">
-	<div class="gf-form-group">
-		<div class="gf-form-inline">
-			<div class="gf-form">
-				<span class="gf-form-label"><i class="fa fa-wrench"></i></span>
-				<span class="gf-form-label width-11">Group by time interval</span>
-				<input type="text" class="gf-form-input width-16" ng-model="ctrl.panelCtrl.panel.interval" ng-blur="ctrl.panelCtrl.refresh();"
-				spellcheck='false' placeholder="example: >10s">
-				<info-popover mode="right-absolute">
-          Set a low limit by having a greater sign: example: >60s
-        </info-popover>
-			</div>
-		</div>
-		<div class="gf-form-inline">
-			<div class="gf-form">
-				<span class="gf-form-label width-10">
-					<a ng-click="ctrl.panelCtrl.toggleEditorHelp(1);" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-						<i class="fa fa-info-circle"></i>
-						&nbsp;alias patterns
-					</a>
-				</span>
-				<span class="gf-form-label width-10">
-					<a ng-click="ctrl.panelCtrl.toggleEditorHelp(2)" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					<i class="fa fa-info-circle"></i>
-						&nbsp;stacking &amp; fill
-					</a>
-				</span>
-				<span class="gf-form-label width-10">
-					<a ng-click="ctrl.panelCtrl.toggleEditorHelp(3)" bs-tooltip="'click to show helpful info'" data-placement="bottom">
-					<i class="fa fa-info-circle"></i>
-						&nbsp;group by time
-					</a>
-				</span>
-			</div>
-		</div>
-	</div>
-</section>
-
-<div class="editor-row">
-	<div class="grafana-info-box span6" ng-if="ctrl.panelCtrl.editorHelpIndex === 1">
-		<h5>Alias patterns</h5>
-		<ul>
-			<li>$m = replaced with measurement name</li>
-			<li>$measurement = replaced with measurement name</li>
-			<li>$1 - $9 = replaced with part of measurement name (if you separate your measurement name with dots)</li>
-			<li>$col = replaced with column name</li>
-			<li>$tag_exampletag = replaced with the value of the <i>exampletag</i> tag</li>
-			<li>You can also use [[tag_exampletag]] pattern replacement syntax</li>
-		</ul>
-	</div>
-
-	<div class="grafana-info-box span6" ng-if="ctrl.panelCtrl.editorHelpIndex === 2">
-		<h5>Stacking and fill</h5>
-		<ul>
-			<li>When stacking is enabled it is important that points align</li>
-			<li>If there are missing points for one series it can cause gaps or missing bars</li>
-			<li>You must use fill(0), and select a group by time low limit</li>
-			<li>Use the group by time option below your queries and specify for example &gt;10s if your metrics are written every 10 seconds</li>
-			<li>This will insert zeros for series that are missing measurements and will make stacking work properly</li>
-		</ul>
-	</div>
-
-	<div class="grafana-info-box span6" ng-if="ctrl.panelCtrl.editorHelpIndex === 3">
-		<h5>Group by time</h5>
-		<ul>
-			<li>Group by time is important, otherwise the query could return many thousands of datapoints that will slow down Grafana</li>
-			<li>Leave the group by time field empty for each query and it will be calculated based on time range and pixel width of the graph</li>
-			<li>If you use fill(0) or fill(null) set a low limit for the auto group by time interval</li>
-			<li>The low limit can only be set in the group by time option below your queries</li>
-			<li>You set a low limit by adding a greater sign before the interval</li>
-			<li>Example: &gt;60s if you write metrics to InfluxDB every 60 seconds</li>
-		</ul>
-	</div>
-</div>
-
-

+ 4 - 0
public/app/plugins/datasource/influxdb/plugin.json

@@ -8,6 +8,10 @@
   "annotations": true,
   "alerting": true,
 
+  "queryOptions": {
+    "minInterval": true
+  },
+
   "info": {
     "author": {
       "name": "Grafana Project",

+ 28 - 0
public/app/plugins/datasource/influxdb/query_help.md

@@ -0,0 +1,28 @@
+#### Alias patterns
+- replaced with measurement name
+- $measurement = replaced with measurement name
+- $1 - $9 = replaced with part of measurement name (if you separate your measurement name with dots)
+- $col = replaced with column name
+- $tag_exampletag = replaced with the value of the <i>exampletag</i> tag
+- You can also use [[tag_exampletag]] pattern replacement syntax
+
+#### Stacking and fill
+- When stacking is enabled it is important that points align
+- If there are missing points for one series it can cause gaps or missing bars
+- You must use fill(0), and select a group by time low limit
+- Use the group by time option below your queries and specify for example &gt;10s if your metrics are written every 10 seconds
+- This will insert zeros for series that are missing measurements and will make stacking work properly
+
+#### Group by time
+- Group by time is important, otherwise the query could return many thousands of datapoints that will slow down Grafana
+- Leave the group by time field empty for each query and it will be calculated based on time range and pixel width of the graph
+- If you use fill(0) or fill(null) set a low limit for the auto group by time interval
+- The low limit can only be set in the group by time option below your queries
+- You set a low limit by adding a greater sign before the interval
+- Example: &gt;60s if you write metrics to InfluxDB every 60 seconds
+
+#### Documentation links:
+
+[Grafana's InfluxDB Documentation](http://docs.grafana.org/features/datasources/influxdb)
+
+[Official InfluxDB Documentation](https://docs.influxdata.com/influxdb)

+ 9 - 0
public/app/plugins/datasource/influxdb/query_part.ts

@@ -230,6 +230,15 @@ register({
   renderer: functionRenderer,
 });
 
+register({
+  type: 'non_negative_difference',
+  addStrategy: addTransformationStrategy,
+  category: categories.Transformations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
 register({
   type: 'moving_average',
   addStrategy: addTransformationStrategy,

Некоторые файлы не были показаны из-за большого количества измененных файлов