Browse Source

Merge branch 'master' into WPH95-feature/add_es_alerting

Marcus Efraimsson 7 năm trước cách đây
mục cha
commit
1324a67cbd
49 tập tin đã thay đổi với 615 bổ sung403 xóa
  1. 4 0
      CHANGELOG.md
  2. 7 7
      docs/sources/administration/provisioning.md
  3. 3 3
      docs/sources/guides/whats-new-in-v4-1.md
  4. 1 1
      docs/sources/guides/whats-new-in-v4-2.md
  5. 1 1
      docs/sources/guides/whats-new-in-v4-6.md
  6. 26 10
      docs/sources/http_api/alerting.md
  7. 1 3
      docs/sources/installation/configuration.md
  8. 3 3
      docs/sources/installation/debian.md
  9. 5 5
      docs/sources/installation/rpm.md
  10. 1 1
      docs/sources/installation/windows.md
  11. 1 1
      docs/sources/plugins/developing/panels.md
  12. 1 1
      docs/sources/tutorials/screencasts.md
  13. 3 1
      pkg/cmd/grafana-cli/commands/commands.go
  14. 12 17
      pkg/cmd/grafana-server/server.go
  15. 9 0
      pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go
  16. 35 4
      pkg/registry/registry.go
  17. 3 3
      pkg/services/provisioning/dashboards/config_reader_test.go
  18. 4 4
      pkg/services/provisioning/dashboards/file_reader_test.go
  19. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/broken-configs/commented.yaml
  20. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml
  21. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/sample.yaml
  22. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml
  23. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/empty-json.json
  24. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/invalid.json
  25. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json
  26. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json
  27. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json
  28. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json
  29. 6 7
      pkg/services/sqlstore/dashboard_snapshot_test.go
  30. 1 1
      pkg/services/sqlstore/migrations/migrations_test.go
  31. 0 21
      pkg/services/sqlstore/migrations/org_mig.go
  32. 1 1
      pkg/services/sqlstore/migrator/migrator.go
  33. 1 0
      pkg/services/sqlstore/quota_test.go
  34. 161 140
      pkg/services/sqlstore/sqlstore.go
  35. 3 1
      pkg/setting/setting.go
  36. 5 5
      public/app/core/utils/kbn.ts
  37. 1 1
      public/app/features/panel/metrics_panel_ctrl.ts
  38. 65 0
      public/app/features/panel/specs/metrics_panel_ctrl.jest.ts
  39. 5 14
      public/app/plugins/datasource/influxdb/response_parser.ts
  40. 0 26
      public/app/plugins/datasource/influxdb/specs/response_parser.jest.ts
  41. 36 16
      public/app/plugins/datasource/prometheus/datasource.ts
  42. 19 9
      public/app/plugins/datasource/prometheus/partials/config.html
  43. 5 4
      public/app/plugins/datasource/prometheus/partials/query.editor.html
  44. 108 76
      public/app/plugins/datasource/prometheus/specs/datasource_specs.ts
  45. 2 2
      public/app/plugins/panel/graph/graph.ts
  46. 4 0
      public/app/plugins/panel/graph/legend.ts
  47. 8 0
      public/sass/pages/_dashboard.scss
  48. 40 0
      scripts/tag_release.sh
  49. 24 14
      scripts/webpack/webpack.dev.js

+ 4 - 0
CHANGELOG.md

@@ -13,6 +13,10 @@
 * **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813)
 * **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813)
 * **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338)
 * **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338)
 
 
+# 5.1.3 (2018-05-16)
+
+* **Scroll**: Graph panel / legend texts shifts on the left each time we move scrollbar on firefox [#11830](https://github.com/grafana/grafana/issues/11830)
+
 # 5.1.2 (2018-05-09)
 # 5.1.2 (2018-05-09)
 
 
 * **Database**: Fix MySql migration issue [#11862](https://github.com/grafana/grafana/issues/11862)
 * **Database**: Fix MySql migration issue [#11862](https://github.com/grafana/grafana/issues/11862)

+ 7 - 7
docs/sources/administration/provisioning.md

@@ -94,7 +94,7 @@ deleteDatasources:
     orgId: 1
     orgId: 1
 
 
 # list of datasources to insert/update depending
 # list of datasources to insert/update depending
-# whats available in the database
+# what's available in the database
 datasources:
 datasources:
   # <string, required> name of the datasource. Required
   # <string, required> name of the datasource. Required
 - name: Graphite
 - name: Graphite
@@ -154,7 +154,7 @@ Since not all datasources have the same configuration settings we only have the
 | tlsAuthWithCACert | boolean | *All* | Enable TLS authentication using CA cert |
 | tlsAuthWithCACert | boolean | *All* | Enable TLS authentication using CA cert |
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | graphiteVersion | string | Graphite |  Graphite version  |
 | graphiteVersion | string | Graphite |  Graphite version  |
-| timeInterval | string | Elastic, Influxdb & Prometheus | Lowest interval/step value that should be used for this data source |
+| timeInterval | string | Elastic, InfluxDB & Prometheus | Lowest interval/step value that should be used for this data source |
 | esVersion | string | Elastic | Elasticsearch version as an number (2/5/56) |
 | esVersion | string | Elastic | Elasticsearch version as an number (2/5/56) |
 | timeField | string | Elastic | Which field that should be used as timestamp |
 | timeField | string | Elastic | Which field that should be used as timestamp |
 | interval | string | Elastic | Index date time format |
 | interval | string | Elastic | Index date time format |
@@ -162,9 +162,9 @@ Since not all datasources have the same configuration settings we only have the
 | assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
 | assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
 | defaultRegion | string | Cloudwatch | AWS region |
 | defaultRegion | string | Cloudwatch | AWS region |
 | customMetricsNamespaces | string | Cloudwatch | Namespaces of Custom Metrics |
 | customMetricsNamespaces | string | Cloudwatch | Namespaces of Custom Metrics |
-| tsdbVersion | string | OpenTsdb | Version |
-| tsdbResolution | string | OpenTsdb | Resolution |
-| sslmode | string | Postgre | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
+| tsdbVersion | string | OpenTSDB | Version |
+| tsdbResolution | string | OpenTSDB | Resolution |
+| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
 
 
 #### Secure Json Data
 #### Secure Json Data
 
 
@@ -177,8 +177,8 @@ Secure json data is a map of settings that will be encrypted with [secret key](/
 | tlsCACert | string | *All* |CA cert for out going requests |
 | tlsCACert | string | *All* |CA cert for out going requests |
 | tlsClientCert | string | *All* |TLS Client cert for outgoing requests |
 | tlsClientCert | string | *All* |TLS Client cert for outgoing requests |
 | tlsClientKey | string | *All* |TLS Client key for outgoing requests |
 | tlsClientKey | string | *All* |TLS Client key for outgoing requests |
-| password | string | Postgre | password |
-| user | string | Postgre | user |
+| password | string | PostgreSQL | password |
+| user | string | PostgreSQL | user |
 | accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch |
 | accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch |
 | secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch |
 | secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch |
 
 

+ 3 - 3
docs/sources/guides/whats-new-in-v4-1.md

@@ -11,7 +11,7 @@ weight = 3
 +++
 +++
 
 
 
 
-## Whats new in Grafana v4.1
+## What's new in Grafana v4.1
 - **Graph**: Support for shared tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274)
 - **Graph**: Support for shared tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274)
 - **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin)
 - **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin)
 - **Opsgenie**: Add OpsGenie notification integratiion [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc)
 - **Opsgenie**: Add OpsGenie notification integratiion [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc)
@@ -24,7 +24,7 @@ weight = 3
 
 
 {{< imgbox max-width="60%" img="/img/docs/v41/shared_tooltip.gif" caption="Shared tooltip" >}}
 {{< imgbox max-width="60%" img="/img/docs/v41/shared_tooltip.gif" caption="Shared tooltip" >}}
 
 
-Showing the tooltip on all panels at the same time has been a long standing request in Grafana and we are really happy to finally be able to release it. 
+Showing the tooltip on all panels at the same time has been a long standing request in Grafana and we are really happy to finally be able to release it.
 You can enable/disable the shared tooltip from the dashboard settings menu or cycle between default, shared tooltip and shared crosshair by pressing `CTRL + O` or `CMD + O`.
 You can enable/disable the shared tooltip from the dashboard settings menu or cycle between default, shared tooltip and shared crosshair by pressing `CTRL + O` or `CMD + O`.
 
 
 <div class="clearfix"></div>
 <div class="clearfix"></div>
@@ -50,7 +50,7 @@ Panels with a help text available have a little indicator in the top left corner
 In Grafana 4.1.0 you can configure your Cloudwatch data source with `access key` and `secret key` directly in the data source configuration page.
 In Grafana 4.1.0 you can configure your Cloudwatch data source with `access key` and `secret key` directly in the data source configuration page.
 This enables people to use the Cloudwatch data source without having access to the filesystem where Grafana is running.
 This enables people to use the Cloudwatch data source without having access to the filesystem where Grafana is running.
 
 
-Once the `access key` and `secret key` have been saved the user will no longer be able to view them. 
+Once the `access key` and `secret key` have been saved the user will no longer be able to view them.
 <div class="clearfix"></div>
 <div class="clearfix"></div>
 
 
 ## Upgrade & Breaking changes
 ## Upgrade & Breaking changes

+ 1 - 1
docs/sources/guides/whats-new-in-v4-2.md

@@ -10,7 +10,7 @@ parent = "whatsnew"
 weight = -1
 weight = -1
 +++
 +++
 
 
-## Whats new in Grafana v4.2
+## What's new in Grafana v4.2
 
 
 Grafana v4.2 Beta is now [available for download](https://grafana.com/grafana/download/4.2.0).
 Grafana v4.2 Beta is now [available for download](https://grafana.com/grafana/download/4.2.0).
 Just like the last release this one contains lots bug fixes and minor improvements.
 Just like the last release this one contains lots bug fixes and minor improvements.

+ 1 - 1
docs/sources/guides/whats-new-in-v4-6.md

@@ -64,7 +64,7 @@ This makes exploring and filtering Prometheus data much easier.
 * **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250)
 * **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250)
 * **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367)
 * **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367)
 * **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739)
 * **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739)
-* **Slack**: Allow images to be uploaded to slack when Token is precent [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8)
+* **Slack**: Allow images to be uploaded to slack when Token is present [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8)
 * **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn)
 * **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn)
 * **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123)
 * **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123)
 * **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo)
 * **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo)

+ 26 - 10
docs/sources/http_api/alerting.md

@@ -49,18 +49,15 @@ Content-Type: application/json
   {
   {
     "id": 1,
     "id": 1,
     "dashboardId": 1,
     "dashboardId": 1,
+    "dashboardUId": "ABcdEFghij"
+    "dashboardSlug": "sensors",
     "panelId": 1,
     "panelId": 1,
     "name": "fire place sensor",
     "name": "fire place sensor",
-    "message": "Someone is trying to break in through the fire place",
     "state": "alerting",
     "state": "alerting",
+    "message": "Someone is trying to break in through the fire place",
+    "newStateDate": "2018-05-14T05:55:20+02:00",
     "evalDate": "0001-01-01T00:00:00Z",
     "evalDate": "0001-01-01T00:00:00Z",
-    "evalData": [
-      {
-        "metric": "fire",
-        "tags": null,
-        "value": 5.349999999999999
-      }
-    "newStateDate": "2016-12-25",
+    "evalData": null,
     "executionError": "",
     "executionError": "",
     "url": "http://grafana.com/dashboard/db/sensors"
     "url": "http://grafana.com/dashboard/db/sensors"
   }
   }
@@ -88,16 +85,35 @@ Content-Type: application/json
 {
 {
   "id": 1,
   "id": 1,
   "dashboardId": 1,
   "dashboardId": 1,
+  "dashboardUId": "ABcdEFghij"
+  "dashboardSlug": "sensors",
   "panelId": 1,
   "panelId": 1,
   "name": "fire place sensor",
   "name": "fire place sensor",
-  "message": "Someone is trying to break in through the fire place",
   "state": "alerting",
   "state": "alerting",
-  "newStateDate": "2016-12-25",
+  "message": "Someone is trying to break in through the fire place",
+  "newStateDate": "2018-05-14T05:55:20+02:00",
+  "evalDate": "0001-01-01T00:00:00Z",
+  "evalData": "evalMatches": [
+    {
+      "metric": "movement",
+      "tags": {
+        "name": "fireplace_chimney"
+      },
+      "value": 98.765
+    }
+  ],
   "executionError": "",
   "executionError": "",
   "url": "http://grafana.com/dashboard/db/sensors"
   "url": "http://grafana.com/dashboard/db/sensors"
 }
 }
 ```
 ```
 
 
+**Important Note**:
+"evalMatches" data is cached in the db when and only when the state of the alert changes
+(e.g. transitioning from "ok" to "alerting" state).
+
+If data from one server triggers the alert first and, before that server is seen leaving alerting state,
+a second server also enters a state that would trigger the alert, the second server will not be visible in "evalMatches" data.
+
 ## Pause alert
 ## Pause alert
 
 
 `POST /api/alerts/:id/pause`
 `POST /api/alerts/:id/pause`

+ 1 - 3
docs/sources/installation/configuration.md

@@ -93,8 +93,6 @@ Directory where grafana will automatically scan and look for plugins
 
 
 ### provisioning
 ### provisioning
 
 
-> This feature is available in 5.0+
-
 Folder that contains [provisioning](/administration/provisioning) config files that grafana will apply on startup. Dashboards will be reloaded when the json files changes
 Folder that contains [provisioning](/administration/provisioning) config files that grafana will apply on startup. Dashboards will be reloaded when the json files changes
 
 
 ## [server]
 ## [server]
@@ -717,7 +715,7 @@ Analytics ID here. By default this feature is disabled.
 
 
 ## [dashboards]
 ## [dashboards]
 
 
-### versions_to_keep (introduced in v5.0)
+### versions_to_keep
 
 
 Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1.
 Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1.
 
 

+ 3 - 3
docs/sources/installation/debian.md

@@ -15,7 +15,7 @@ weight = 1
 
 
 Description | Download
 Description | Download
 ------------ | -------------
 ------------ | -------------
-Stable for Debian-based Linux | [grafana_5.1.2_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.2_amd64.deb)
+Stable for Debian-based Linux | [grafana_5.1.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.3_amd64.deb)
 <!--
 <!--
 Beta for Debian-based Linux | [grafana_5.1.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0-beta1_amd64.deb)
 Beta for Debian-based Linux | [grafana_5.1.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0-beta1_amd64.deb)
 -->
 -->
@@ -27,9 +27,9 @@ installation.
 
 
 
 
 ```bash
 ```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.2_amd64.deb
+wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.3_amd64.deb
 sudo apt-get install -y adduser libfontconfig
 sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_5.1.2_amd64.deb
+sudo dpkg -i grafana_5.1.3_amd64.deb
 ```
 ```
 
 
 <!-- ## Install Latest Beta
 <!-- ## Install Latest Beta

+ 5 - 5
docs/sources/installation/rpm.md

@@ -15,7 +15,7 @@ weight = 2
 
 
 Description | Download
 Description | Download
 ------------ | -------------
 ------------ | -------------
-Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.2 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.2-1.x86_64.rpm)
+Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm)
 <!--
 <!--
 Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm)
 Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm)
 -->
 -->
@@ -28,7 +28,7 @@ installation.
 You can install Grafana using Yum directly.
 You can install Grafana using Yum directly.
 
 
 ```bash
 ```bash
-$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.2-1.x86_64.rpm
+$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm
 ```
 ```
 
 
 <!-- ## Install Beta
 <!-- ## Install Beta
@@ -42,15 +42,15 @@ Or install manually using `rpm`.
 #### On CentOS / Fedora / Redhat:
 #### On CentOS / Fedora / Redhat:
 
 
 ```bash
 ```bash
-$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.2-1.x86_64.rpm
+$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm
 $ sudo yum install initscripts fontconfig
 $ sudo yum install initscripts fontconfig
-$ sudo rpm -Uvh grafana-5.1.2-1.x86_64.rpm
+$ sudo rpm -Uvh grafana-5.1.3-1.x86_64.rpm
 ```
 ```
 
 
 #### On OpenSuse:
 #### On OpenSuse:
 
 
 ```bash
 ```bash
-$ sudo rpm -i --nodeps grafana-5.1.2-1.x86_64.rpm
+$ sudo rpm -i --nodeps grafana-5.1.3-1.x86_64.rpm
 ```
 ```
 
 
 ## Install via YUM Repository
 ## Install via YUM Repository

+ 1 - 1
docs/sources/installation/windows.md

@@ -12,7 +12,7 @@ weight = 3
 
 
 Description | Download
 Description | Download
 ------------ | -------------
 ------------ | -------------
-Latest stable package for Windows | [grafana-5.1.2.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.2.windows-x64.zip)
+Latest stable package for Windows | [grafana-5.1.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3.windows-x64.zip)
 
 
 <!--
 <!--
 Latest beta package for Windows | [grafana.5.1.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip)
 Latest beta package for Windows | [grafana.5.1.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip)

+ 1 - 1
docs/sources/plugins/developing/panels.md

@@ -25,7 +25,7 @@ export class MyPanelCtrl extends PanelCtrl {
   ...
   ...
 ```
 ```
 
 
-In this case, make sure the template has a single `<div>...</div>` root.  The plugin loader will modifiy that element adding a scrollbar.
+In this case, make sure the template has a single `<div>...</div>` root.  The plugin loader will modify that element adding a scrollbar.
 
 
 
 
 
 

+ 1 - 1
docs/sources/tutorials/screencasts.md

@@ -94,7 +94,7 @@ weight = 10
     </a>
     </a>
     <figcaption>
     <figcaption>
        <a href="https://youtu.be/FC13uhFRsVw?list=PLDGkOdUX1Ujo3wHw9-z5Vo12YLqXRjzg2" target="_blank" rel="noopener noreferrer">
        <a href="https://youtu.be/FC13uhFRsVw?list=PLDGkOdUX1Ujo3wHw9-z5Vo12YLqXRjzg2" target="_blank" rel="noopener noreferrer">
-       #3 Whats New In Grafana 2.0
+       #3 What's New In Grafana 2.0
        </a>
        </a>
     </figcaption>
     </figcaption>
   </figure>
   </figure>

+ 3 - 1
pkg/cmd/grafana-cli/commands/commands.go

@@ -22,7 +22,9 @@ func runDbCommand(command func(commandLine CommandLine) error) func(context *cli
 			Args:     flag.Args(),
 			Args:     flag.Args(),
 		})
 		})
 
 
-		sqlstore.NewEngine()
+		engine := &sqlstore.SqlStore{}
+		engine.Cfg = cfg
+		engine.Init()
 
 
 		if err := command(cmd); err != nil {
 		if err := command(cmd); err != nil {
 			logger.Errorf("\n%s: ", color.RedString("Error"))
 			logger.Errorf("\n%s: ", color.RedString("Error"))

+ 12 - 17
pkg/cmd/grafana-server/server.go

@@ -8,7 +8,6 @@ import (
 	"net"
 	"net"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
-	"reflect"
 	"strconv"
 	"strconv"
 	"time"
 	"time"
 
 
@@ -16,14 +15,12 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/registry"
-	"github.com/grafana/grafana/pkg/services/dashboards"
 
 
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/sync/errgroup"
 
 
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/login"
-	"github.com/grafana/grafana/pkg/services/sqlstore"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 
 
 	"github.com/grafana/grafana/pkg/social"
 	"github.com/grafana/grafana/pkg/social"
@@ -37,6 +34,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/services/notifications"
 	_ "github.com/grafana/grafana/pkg/services/notifications"
 	_ "github.com/grafana/grafana/pkg/services/provisioning"
 	_ "github.com/grafana/grafana/pkg/services/provisioning"
 	_ "github.com/grafana/grafana/pkg/services/search"
 	_ "github.com/grafana/grafana/pkg/services/search"
+	_ "github.com/grafana/grafana/pkg/services/sqlstore"
 	_ "github.com/grafana/grafana/pkg/tracing"
 	_ "github.com/grafana/grafana/pkg/tracing"
 )
 )
 
 
@@ -70,17 +68,12 @@ func (g *GrafanaServerImpl) Run() error {
 	g.loadConfiguration()
 	g.loadConfiguration()
 	g.writePIDFile()
 	g.writePIDFile()
 
 
-	// initSql
-	sqlstore.NewEngine() // TODO: this should return an error
-	sqlstore.EnsureAdminUser()
-
 	login.Init()
 	login.Init()
 	social.NewOAuthService()
 	social.NewOAuthService()
 
 
 	serviceGraph := inject.Graph{}
 	serviceGraph := inject.Graph{}
 	serviceGraph.Provide(&inject.Object{Value: bus.GetBus()})
 	serviceGraph.Provide(&inject.Object{Value: bus.GetBus()})
 	serviceGraph.Provide(&inject.Object{Value: g.cfg})
 	serviceGraph.Provide(&inject.Object{Value: g.cfg})
-	serviceGraph.Provide(&inject.Object{Value: dashboards.NewProvisioningService()})
 	serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
 	serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
 
 
 	// self registered services
 	// self registered services
@@ -88,7 +81,7 @@ func (g *GrafanaServerImpl) Run() error {
 
 
 	// Add all services to dependency graph
 	// Add all services to dependency graph
 	for _, service := range services {
 	for _, service := range services {
-		serviceGraph.Provide(&inject.Object{Value: service})
+		serviceGraph.Provide(&inject.Object{Value: service.Instance})
 	}
 	}
 
 
 	serviceGraph.Provide(&inject.Object{Value: g})
 	serviceGraph.Provide(&inject.Object{Value: g})
@@ -100,25 +93,27 @@ func (g *GrafanaServerImpl) Run() error {
 
 
 	// Init & start services
 	// Init & start services
 	for _, service := range services {
 	for _, service := range services {
-		if registry.IsDisabled(service) {
+		if registry.IsDisabled(service.Instance) {
 			continue
 			continue
 		}
 		}
 
 
-		g.log.Info("Initializing " + reflect.TypeOf(service).Elem().Name())
+		g.log.Info("Initializing " + service.Name)
 
 
-		if err := service.Init(); err != nil {
+		if err := service.Instance.Init(); err != nil {
 			return fmt.Errorf("Service init failed: %v", err)
 			return fmt.Errorf("Service init failed: %v", err)
 		}
 		}
 	}
 	}
 
 
 	// Start background services
 	// Start background services
-	for index := range services {
-		service, ok := services[index].(registry.BackgroundService)
+	for _, srv := range services {
+		// variable needed for accessing loop variable in function callback
+		descriptor := srv
+		service, ok := srv.Instance.(registry.BackgroundService)
 		if !ok {
 		if !ok {
 			continue
 			continue
 		}
 		}
 
 
-		if registry.IsDisabled(services[index]) {
+		if registry.IsDisabled(descriptor.Instance) {
 			continue
 			continue
 		}
 		}
 
 
@@ -133,9 +128,9 @@ func (g *GrafanaServerImpl) Run() error {
 
 
 			// If error is not canceled then the service crashed
 			// If error is not canceled then the service crashed
 			if err != context.Canceled && err != nil {
 			if err != context.Canceled && err != nil {
-				g.log.Error("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
+				g.log.Error("Stopped "+descriptor.Name, "reason", err)
 			} else {
 			} else {
-				g.log.Info("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
+				g.log.Info("Stopped "+descriptor.Name, "reason", err)
 			}
 			}
 
 
 			// Mark that we are in shutdown mode
 			// Mark that we are in shutdown mode

+ 9 - 0
pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"fmt"
 
 
 	"github.com/grafana/grafana/pkg/components/null"
 	"github.com/grafana/grafana/pkg/components/null"
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/tsdb"
 	"github.com/grafana/grafana/pkg/tsdb"
@@ -79,6 +80,14 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
 			qr.ErrorString = r.Error
 			qr.ErrorString = r.Error
 		}
 		}
 
 
+		if r.MetaJson != "" {
+			metaJson, err := simplejson.NewJson([]byte(r.MetaJson))
+			if err != nil {
+				tw.logger.Error("Error parsing JSON Meta field: " + err.Error())
+			}
+			qr.Meta = metaJson
+		}
+
 		for _, s := range r.GetSeries() {
 		for _, s := range r.GetSeries() {
 			points := tsdb.TimeSeriesPoints{}
 			points := tsdb.TimeSeriesPoints{}
 
 

+ 35 - 4
pkg/registry/registry.go

@@ -2,15 +2,35 @@ package registry
 
 
 import (
 import (
 	"context"
 	"context"
+	"reflect"
+	"sort"
 )
 )
 
 
-var services = []Service{}
+type Descriptor struct {
+	Name         string
+	Instance     Service
+	InitPriority Priority
+}
+
+var services []*Descriptor
 
 
-func RegisterService(srv Service) {
-	services = append(services, srv)
+func RegisterService(instance Service) {
+	services = append(services, &Descriptor{
+		Name:         reflect.TypeOf(instance).Elem().Name(),
+		Instance:     instance,
+		InitPriority: Low,
+	})
 }
 }
 
 
-func GetServices() []Service {
+func Register(descriptor *Descriptor) {
+	services = append(services, descriptor)
+}
+
+func GetServices() []*Descriptor {
+	sort.Slice(services, func(i, j int) bool {
+		return services[i].InitPriority > services[j].InitPriority
+	})
+
 	return services
 	return services
 }
 }
 
 
@@ -27,7 +47,18 @@ type BackgroundService interface {
 	Run(ctx context.Context) error
 	Run(ctx context.Context) error
 }
 }
 
 
+type HasInitPriority interface {
+	GetInitPriority() Priority
+}
+
 func IsDisabled(srv Service) bool {
 func IsDisabled(srv Service) bool {
 	canBeDisabled, ok := srv.(CanBeDisabled)
 	canBeDisabled, ok := srv.(CanBeDisabled)
 	return ok && canBeDisabled.IsDisabled()
 	return ok && canBeDisabled.IsDisabled()
 }
 }
+
+type Priority int
+
+const (
+	High Priority = 100
+	Low  Priority = 0
+)

+ 3 - 3
pkg/services/provisioning/dashboards/config_reader_test.go

@@ -8,9 +8,9 @@ import (
 )
 )
 
 
 var (
 var (
-	simpleDashboardConfig = "./test-configs/dashboards-from-disk"
-	oldVersion            = "./test-configs/version-0"
-	brokenConfigs         = "./test-configs/broken-configs"
+	simpleDashboardConfig = "./testdata/test-configs/dashboards-from-disk"
+	oldVersion            = "./testdata/test-configs/version-0"
+	brokenConfigs         = "./testdata/test-configs/broken-configs"
 )
 )
 
 
 func TestDashboardsAsConfig(t *testing.T) {
 func TestDashboardsAsConfig(t *testing.T) {

+ 4 - 4
pkg/services/provisioning/dashboards/file_reader_test.go

@@ -15,10 +15,10 @@ import (
 )
 )
 
 
 var (
 var (
-	defaultDashboards = "./test-dashboards/folder-one"
-	brokenDashboards  = "./test-dashboards/broken-dashboards"
-	oneDashboard      = "./test-dashboards/one-dashboard"
-	containingId      = "./test-dashboards/containing-id"
+	defaultDashboards = "./testdata/test-dashboards/folder-one"
+	brokenDashboards  = "./testdata/test-dashboards/broken-dashboards"
+	oneDashboard      = "./testdata/test-dashboards/one-dashboard"
+	containingId      = "./testdata/test-dashboards/containing-id"
 
 
 	fakeService *fakeDashboardProvisioningService
 	fakeService *fakeDashboardProvisioningService
 )
 )

+ 0 - 0
pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml → pkg/services/provisioning/dashboards/testdata/test-configs/broken-configs/commented.yaml


+ 0 - 0
pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml → pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml


+ 0 - 0
pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml → pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/sample.yaml


+ 0 - 0
pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml → pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml


+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/empty-json.json → pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/empty-json.json


+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/broken-dashboards/invalid.json → pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/invalid.json


+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/containing-id/dashboard1.json → pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json


+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard1.json → pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json


+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/folder-one/dashboard2.json → pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json


+ 0 - 0
pkg/services/provisioning/dashboards/test-dashboards/one-dashboard/dashboard1.json → pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json


+ 6 - 7
pkg/services/sqlstore/dashboard_snapshot_test.go

@@ -4,7 +4,6 @@ import (
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
-	"github.com/go-xorm/xorm"
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -110,14 +109,14 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 }
 }
 
 
 func TestDeleteExpiredSnapshots(t *testing.T) {
 func TestDeleteExpiredSnapshots(t *testing.T) {
-	x := InitTestDB(t)
+	sqlstore := InitTestDB(t)
 
 
 	Convey("Testing dashboard snapshots clean up", t, func() {
 	Convey("Testing dashboard snapshots clean up", t, func() {
 		setting.SnapShotRemoveExpired = true
 		setting.SnapShotRemoveExpired = true
 
 
-		notExpiredsnapshot := createTestSnapshot(x, "key1", 1200)
-		createTestSnapshot(x, "key2", -1200)
-		createTestSnapshot(x, "key3", -1200)
+		notExpiredsnapshot := createTestSnapshot(sqlstore, "key1", 48000)
+		createTestSnapshot(sqlstore, "key2", -1200)
+		createTestSnapshot(sqlstore, "key3", -1200)
 
 
 		err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
 		err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
 		So(err, ShouldBeNil)
 		So(err, ShouldBeNil)
@@ -146,7 +145,7 @@ func TestDeleteExpiredSnapshots(t *testing.T) {
 	})
 	})
 }
 }
 
 
-func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardSnapshot {
+func createTestSnapshot(sqlstore *SqlStore, key string, expires int64) *m.DashboardSnapshot {
 	cmd := m.CreateDashboardSnapshotCommand{
 	cmd := m.CreateDashboardSnapshotCommand{
 		Key:       key,
 		Key:       key,
 		DeleteKey: "delete" + key,
 		DeleteKey: "delete" + key,
@@ -163,7 +162,7 @@ func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardS
 	// Set expiry date manually - to be able to create expired snapshots
 	// Set expiry date manually - to be able to create expired snapshots
 	if expires < 0 {
 	if expires < 0 {
 		expireDate := time.Now().Add(time.Second * time.Duration(expires))
 		expireDate := time.Now().Add(time.Second * time.Duration(expires))
-		_, err = x.Exec("UPDATE dashboard_snapshot SET expires = ? WHERE id = ?", expireDate, cmd.Result.Id)
+		_, err = sqlstore.engine.Exec("UPDATE dashboard_snapshot SET expires = ? WHERE id = ?", expireDate, cmd.Result.Id)
 		So(err, ShouldBeNil)
 		So(err, ShouldBeNil)
 	}
 	}
 
 

+ 1 - 1
pkg/services/sqlstore/migrations/migrations_test.go

@@ -39,7 +39,7 @@ func TestMigrations(t *testing.T) {
 			has, err := x.SQL(sql).Get(&r)
 			has, err := x.SQL(sql).Get(&r)
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
 			So(has, ShouldBeTrue)
 			So(has, ShouldBeTrue)
-			expectedMigrations := mg.MigrationsCount() - 2 //we currently skip to migrations. We should rewrite skipped migrations to write in the log as well. until then we have to keep this
+			expectedMigrations := mg.MigrationsCount() //we currently skip to migrations. We should rewrite skipped migrations to write in the log as well. until then we have to keep this
 			So(r.Count, ShouldEqual, expectedMigrations)
 			So(r.Count, ShouldEqual, expectedMigrations)
 
 
 			mg = NewMigrator(x)
 			mg = NewMigrator(x)

+ 0 - 21
pkg/services/sqlstore/migrations/org_mig.go

@@ -48,27 +48,6 @@ func addOrgMigrations(mg *Migrator) {
 	mg.AddMigration("create org_user table v1", NewAddTableMigration(orgUserV1))
 	mg.AddMigration("create org_user table v1", NewAddTableMigration(orgUserV1))
 	addTableIndicesMigrations(mg, "v1", orgUserV1)
 	addTableIndicesMigrations(mg, "v1", orgUserV1)
 
 
-	//-------  copy data from old table-------------------
-	mg.AddMigration("copy data account to org", NewCopyTableDataMigration("org", "account", map[string]string{
-		"id":      "id",
-		"version": "version",
-		"name":    "name",
-		"created": "created",
-		"updated": "updated",
-	}).IfTableExists("account"))
-
-	mg.AddMigration("copy data account_user to org_user", NewCopyTableDataMigration("org_user", "account_user", map[string]string{
-		"id":      "id",
-		"org_id":  "account_id",
-		"user_id": "user_id",
-		"role":    "role",
-		"created": "created",
-		"updated": "updated",
-	}).IfTableExists("account_user"))
-
-	mg.AddMigration("Drop old table account", NewDropTableMigration("account"))
-	mg.AddMigration("Drop old table account_user", NewDropTableMigration("account_user"))
-
 	mg.AddMigration("Update org table charset", NewTableCharsetMigration("org", []*Column{
 	mg.AddMigration("Update org table charset", NewTableCharsetMigration("org", []*Column{
 		{Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false},
 		{Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false},
 		{Name: "address1", Type: DB_NVarchar, Length: 255, Nullable: true},
 		{Name: "address1", Type: DB_NVarchar, Length: 255, Nullable: true},

+ 1 - 1
pkg/services/sqlstore/migrator/migrator.go

@@ -125,7 +125,7 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
 		sql, args := condition.Sql(mg.dialect)
 		sql, args := condition.Sql(mg.dialect)
 		results, err := sess.SQL(sql).Query(args...)
 		results, err := sess.SQL(sql).Query(args...)
 		if err != nil || len(results) == 0 {
 		if err != nil || len(results) == 0 {
-			mg.Logger.Info("Skipping migration condition not fulfilled", "id", m.Id())
+			mg.Logger.Debug("Skipping migration condition not fulfilled", "id", m.Id())
 			return sess.Rollback()
 			return sess.Rollback()
 		}
 		}
 	}
 	}

+ 1 - 0
pkg/services/sqlstore/quota_test.go

@@ -43,6 +43,7 @@ func TestQuotaCommandsAndQueries(t *testing.T) {
 			Name:   "TestOrg",
 			Name:   "TestOrg",
 			UserId: 1,
 			UserId: 1,
 		}
 		}
+
 		err := CreateOrg(&userCmd)
 		err := CreateOrg(&userCmd)
 		So(err, ShouldBeNil)
 		So(err, ShouldBeNil)
 		orgId = userCmd.Result.Id
 		orgId = userCmd.Result.Id

+ 161 - 140
pkg/services/sqlstore/sqlstore.go

@@ -13,6 +13,7 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/annotations"
 	"github.com/grafana/grafana/pkg/services/annotations"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
@@ -27,151 +28,164 @@ import (
 	_ "github.com/grafana/grafana/pkg/tsdb/mssql"
 	_ "github.com/grafana/grafana/pkg/tsdb/mssql"
 )
 )
 
 
-type DatabaseConfig struct {
-	Type, Host, Name, User, Pwd, Path, SslMode string
-	CaCertPath                                 string
-	ClientKeyPath                              string
-	ClientCertPath                             string
-	ServerCertName                             string
-	MaxOpenConn                                int
-	MaxIdleConn                                int
-	ConnMaxLifetime                            int
-}
-
 var (
 var (
 	x       *xorm.Engine
 	x       *xorm.Engine
 	dialect migrator.Dialect
 	dialect migrator.Dialect
 
 
-	HasEngine bool
-
-	DbCfg DatabaseConfig
-
-	UseSQLite3 bool
-	sqlog      log.Logger = log.New("sqlstore")
+	sqlog log.Logger = log.New("sqlstore")
 )
 )
 
 
-func EnsureAdminUser() {
-	statsQuery := m.GetSystemStatsQuery{}
-
-	if err := bus.Dispatch(&statsQuery); err != nil {
-		log.Fatal(3, "Could not determine if admin user exists: %v", err)
-		return
-	}
-
-	if statsQuery.Result.Users > 0 {
-		return
-	}
-
-	cmd := m.CreateUserCommand{}
-	cmd.Login = setting.AdminUser
-	cmd.Email = setting.AdminUser + "@localhost"
-	cmd.Password = setting.AdminPassword
-	cmd.IsAdmin = true
+func init() {
+	registry.Register(&registry.Descriptor{
+		Name:         "SqlStore",
+		Instance:     &SqlStore{},
+		InitPriority: registry.High,
+	})
+}
 
 
-	if err := bus.Dispatch(&cmd); err != nil {
-		log.Error(3, "Failed to create default admin user", err)
-		return
-	}
+type SqlStore struct {
+	Cfg *setting.Cfg `inject:""`
 
 
-	log.Info("Created default admin user: %v", setting.AdminUser)
+	dbCfg           DatabaseConfig
+	engine          *xorm.Engine
+	log             log.Logger
+	skipEnsureAdmin bool
 }
 }
 
 
-func NewEngine() *xorm.Engine {
-	x, err := getEngine()
+func (ss *SqlStore) Init() error {
+	ss.log = log.New("sqlstore")
+	ss.readConfig()
 
 
-	if err != nil {
-		sqlog.Crit("Fail to connect to database", "error", err)
-		os.Exit(1)
-	}
-
-	err = SetEngine(x)
+	engine, err := ss.getEngine()
 
 
 	if err != nil {
 	if err != nil {
-		sqlog.Error("Fail to initialize orm engine", "error", err)
-		os.Exit(1)
+		return fmt.Errorf("Fail to connect to database: %v", err)
 	}
 	}
 
 
-	return x
-}
+	ss.engine = engine
 
 
-func SetEngine(engine *xorm.Engine) (err error) {
+	// temporarily still set global var
 	x = engine
 	x = engine
 	dialect = migrator.NewDialect(x)
 	dialect = migrator.NewDialect(x)
-
 	migrator := migrator.NewMigrator(x)
 	migrator := migrator.NewMigrator(x)
 	migrations.AddMigrations(migrator)
 	migrations.AddMigrations(migrator)
 
 
 	if err := migrator.Start(); err != nil {
 	if err := migrator.Start(); err != nil {
-		return fmt.Errorf("Sqlstore::Migration failed err: %v\n", err)
+		return fmt.Errorf("Migration failed err: %v", err)
 	}
 	}
 
 
 	// Init repo instances
 	// Init repo instances
 	annotations.SetRepository(&SqlAnnotationRepo{})
 	annotations.SetRepository(&SqlAnnotationRepo{})
+
+	// ensure admin user
+	if ss.skipEnsureAdmin {
+		return nil
+	}
+
+	return ss.ensureAdminUser()
+}
+
+func (ss *SqlStore) ensureAdminUser() error {
+	statsQuery := m.GetSystemStatsQuery{}
+
+	if err := bus.Dispatch(&statsQuery); err != nil {
+		fmt.Errorf("Could not determine if admin user exists: %v", err)
+	}
+
+	if statsQuery.Result.Users > 0 {
+		return nil
+	}
+
+	cmd := m.CreateUserCommand{}
+	cmd.Login = setting.AdminUser
+	cmd.Email = setting.AdminUser + "@localhost"
+	cmd.Password = setting.AdminPassword
+	cmd.IsAdmin = true
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		return fmt.Errorf("Failed to create admin user: %v", err)
+	}
+
+	ss.log.Info("Created default admin user: %v", setting.AdminUser)
+
 	return nil
 	return nil
 }
 }
 
 
-func getEngine() (*xorm.Engine, error) {
-	LoadConfig()
+func (ss *SqlStore) buildConnectionString() (string, error) {
+	cnnstr := ss.dbCfg.ConnectionString
 
 
-	cnnstr := ""
-	switch DbCfg.Type {
+	// special case used by integration tests
+	if cnnstr != "" {
+		return cnnstr, nil
+	}
+
+	switch ss.dbCfg.Type {
 	case migrator.MYSQL:
 	case migrator.MYSQL:
 		protocol := "tcp"
 		protocol := "tcp"
-		if strings.HasPrefix(DbCfg.Host, "/") {
+		if strings.HasPrefix(ss.dbCfg.Host, "/") {
 			protocol = "unix"
 			protocol = "unix"
 		}
 		}
 
 
 		cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true",
 		cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true",
-			url.QueryEscape(DbCfg.User), url.QueryEscape(DbCfg.Pwd), protocol, DbCfg.Host, url.PathEscape(DbCfg.Name))
+			ss.dbCfg.User, ss.dbCfg.Pwd, protocol, ss.dbCfg.Host, ss.dbCfg.Name)
 
 
-		if DbCfg.SslMode == "true" || DbCfg.SslMode == "skip-verify" {
-			tlsCert, err := makeCert("custom", DbCfg)
+		if ss.dbCfg.SslMode == "true" || ss.dbCfg.SslMode == "skip-verify" {
+			tlsCert, err := makeCert("custom", ss.dbCfg)
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return "", err
 			}
 			}
 			mysql.RegisterTLSConfig("custom", tlsCert)
 			mysql.RegisterTLSConfig("custom", tlsCert)
 			cnnstr += "&tls=custom"
 			cnnstr += "&tls=custom"
 		}
 		}
 	case migrator.POSTGRES:
 	case migrator.POSTGRES:
 		var host, port = "127.0.0.1", "5432"
 		var host, port = "127.0.0.1", "5432"
-		fields := strings.Split(DbCfg.Host, ":")
+		fields := strings.Split(ss.dbCfg.Host, ":")
 		if len(fields) > 0 && len(strings.TrimSpace(fields[0])) > 0 {
 		if len(fields) > 0 && len(strings.TrimSpace(fields[0])) > 0 {
 			host = fields[0]
 			host = fields[0]
 		}
 		}
 		if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
 		if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
 			port = fields[1]
 			port = fields[1]
 		}
 		}
-		cnnstr = fmt.Sprintf("user='%s' password='%s' host='%s' port='%s' dbname='%s' sslmode='%s' sslcert='%s' sslkey='%s' sslrootcert='%s'",
-			strings.Replace(DbCfg.User, `'`, `\'`, -1),
-			strings.Replace(DbCfg.Pwd, `'`, `\'`, -1),
-			strings.Replace(host, `'`, `\'`, -1),
-			strings.Replace(port, `'`, `\'`, -1),
-			strings.Replace(DbCfg.Name, `'`, `\'`, -1),
-			strings.Replace(DbCfg.SslMode, `'`, `\'`, -1),
-			strings.Replace(DbCfg.ClientCertPath, `'`, `\'`, -1),
-			strings.Replace(DbCfg.ClientKeyPath, `'`, `\'`, -1),
-			strings.Replace(DbCfg.CaCertPath, `'`, `\'`, -1),
-		)
+		if ss.dbCfg.Pwd == "" {
+			ss.dbCfg.Pwd = "''"
+		}
+		if ss.dbCfg.User == "" {
+			ss.dbCfg.User = "''"
+		}
+		cnnstr = fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", ss.dbCfg.User, ss.dbCfg.Pwd, host, port, ss.dbCfg.Name, ss.dbCfg.SslMode, ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath)
 	case migrator.SQLITE:
 	case migrator.SQLITE:
-		if !filepath.IsAbs(DbCfg.Path) {
-			DbCfg.Path = filepath.Join(setting.DataPath, DbCfg.Path)
+		// special case for tests
+		if !filepath.IsAbs(ss.dbCfg.Path) {
+			ss.dbCfg.Path = filepath.Join(setting.DataPath, ss.dbCfg.Path)
 		}
 		}
-		os.MkdirAll(path.Dir(DbCfg.Path), os.ModePerm)
-		cnnstr = "file:" + DbCfg.Path + "?cache=shared&mode=rwc"
+		os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
+		cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc"
 	default:
 	default:
-		return nil, fmt.Errorf("Unknown database type: %s", DbCfg.Type)
+		return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type)
 	}
 	}
 
 
-	sqlog.Info("Initializing DB", "dbtype", DbCfg.Type)
-	engine, err := xorm.NewEngine(DbCfg.Type, cnnstr)
+	return cnnstr, nil
+}
+
+func (ss *SqlStore) getEngine() (*xorm.Engine, error) {
+	connectionString, err := ss.buildConnectionString()
+
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	engine.SetMaxOpenConns(DbCfg.MaxOpenConn)
-	engine.SetMaxIdleConns(DbCfg.MaxIdleConn)
-	engine.SetConnMaxLifetime(time.Second * time.Duration(DbCfg.ConnMaxLifetime))
-	debugSql := setting.Raw.Section("database").Key("log_queries").MustBool(false)
+	sqlog.Info("Connecting to DB", "dbtype", ss.dbCfg.Type)
+	engine, err := xorm.NewEngine(ss.dbCfg.Type, connectionString)
+	if err != nil {
+		return nil, err
+	}
+
+	engine.SetMaxOpenConns(ss.dbCfg.MaxOpenConn)
+	engine.SetMaxIdleConns(ss.dbCfg.MaxIdleConn)
+	engine.SetConnMaxLifetime(time.Second * time.Duration(ss.dbCfg.ConnMaxLifetime))
+
+	// configure sql logging
+	debugSql := ss.Cfg.Raw.Section("database").Key("log_queries").MustBool(false)
 	if !debugSql {
 	if !debugSql {
 		engine.SetLogger(&xorm.DiscardLogger{})
 		engine.SetLogger(&xorm.DiscardLogger{})
 	} else {
 	} else {
@@ -183,95 +197,90 @@ func getEngine() (*xorm.Engine, error) {
 	return engine, nil
 	return engine, nil
 }
 }
 
 
-func LoadConfig() {
-	sec := setting.Raw.Section("database")
+func (ss *SqlStore) readConfig() {
+	sec := ss.Cfg.Raw.Section("database")
 
 
 	cfgURL := sec.Key("url").String()
 	cfgURL := sec.Key("url").String()
 	if len(cfgURL) != 0 {
 	if len(cfgURL) != 0 {
 		dbURL, _ := url.Parse(cfgURL)
 		dbURL, _ := url.Parse(cfgURL)
-		DbCfg.Type = dbURL.Scheme
-		DbCfg.Host = dbURL.Host
+		ss.dbCfg.Type = dbURL.Scheme
+		ss.dbCfg.Host = dbURL.Host
 
 
 		pathSplit := strings.Split(dbURL.Path, "/")
 		pathSplit := strings.Split(dbURL.Path, "/")
 		if len(pathSplit) > 1 {
 		if len(pathSplit) > 1 {
-			DbCfg.Name = pathSplit[1]
+			ss.dbCfg.Name = pathSplit[1]
 		}
 		}
 
 
 		userInfo := dbURL.User
 		userInfo := dbURL.User
 		if userInfo != nil {
 		if userInfo != nil {
-			DbCfg.User = userInfo.Username()
-			DbCfg.Pwd, _ = userInfo.Password()
+			ss.dbCfg.User = userInfo.Username()
+			ss.dbCfg.Pwd, _ = userInfo.Password()
 		}
 		}
 	} else {
 	} else {
-		DbCfg.Type = sec.Key("type").String()
-		DbCfg.Host = sec.Key("host").String()
-		DbCfg.Name = sec.Key("name").String()
-		DbCfg.User = sec.Key("user").String()
-		if len(DbCfg.Pwd) == 0 {
-			DbCfg.Pwd = sec.Key("password").String()
-		}
-	}
-	DbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0)
-	DbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(0)
-	DbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400)
-
-	if DbCfg.Type == "sqlite3" {
-		UseSQLite3 = true
-		// only allow one connection as sqlite3 has multi threading issues that cause table locks
-		// DbCfg.MaxIdleConn = 1
-		// DbCfg.MaxOpenConn = 1
+		ss.dbCfg.Type = sec.Key("type").String()
+		ss.dbCfg.Host = sec.Key("host").String()
+		ss.dbCfg.Name = sec.Key("name").String()
+		ss.dbCfg.User = sec.Key("user").String()
+		ss.dbCfg.ConnectionString = sec.Key("connection_string").String()
+		ss.dbCfg.Pwd = sec.Key("password").String()
 	}
 	}
-	DbCfg.SslMode = sec.Key("ssl_mode").String()
-	DbCfg.CaCertPath = sec.Key("ca_cert_path").String()
-	DbCfg.ClientKeyPath = sec.Key("client_key_path").String()
-	DbCfg.ClientCertPath = sec.Key("client_cert_path").String()
-	DbCfg.ServerCertName = sec.Key("server_cert_name").String()
-	DbCfg.Path = sec.Key("path").MustString("data/grafana.db")
+
+	ss.dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0)
+	ss.dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2)
+	ss.dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400)
+
+	ss.dbCfg.SslMode = sec.Key("ssl_mode").String()
+	ss.dbCfg.CaCertPath = sec.Key("ca_cert_path").String()
+	ss.dbCfg.ClientKeyPath = sec.Key("client_key_path").String()
+	ss.dbCfg.ClientCertPath = sec.Key("client_cert_path").String()
+	ss.dbCfg.ServerCertName = sec.Key("server_cert_name").String()
+	ss.dbCfg.Path = sec.Key("path").MustString("data/grafana.db")
 }
 }
 
 
-func InitTestDB(t *testing.T) *xorm.Engine {
-	selectedDb := migrator.SQLITE
-	// selectedDb := migrator.MYSQL
-	// selectedDb := migrator.POSTGRES
+func InitTestDB(t *testing.T) *SqlStore {
+	sqlstore := &SqlStore{}
+	sqlstore.skipEnsureAdmin = true
 
 
-	var x *xorm.Engine
-	var err error
+	dbType := migrator.SQLITE
 
 
 	// environment variable present for test db?
 	// environment variable present for test db?
 	if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present {
 	if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present {
-		selectedDb = db
+		dbType = db
 	}
 	}
 
 
-	switch strings.ToLower(selectedDb) {
-	case migrator.MYSQL:
-		x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
-	case migrator.POSTGRES:
-		x, err = xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
+	// set test db config
+	sqlstore.Cfg = setting.NewCfg()
+	sec, _ := sqlstore.Cfg.Raw.NewSection("database")
+	sec.NewKey("type", dbType)
+
+	switch dbType {
+	case "mysql":
+		sec.NewKey("connection_string", sqlutil.TestDB_Mysql.ConnStr)
+	case "postgres":
+		sec.NewKey("connection_string", sqlutil.TestDB_Postgres.ConnStr)
 	default:
 	default:
-		x, err = xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr)
+		sec.NewKey("connection_string", sqlutil.TestDB_Sqlite3.ConnStr)
 	}
 	}
 
 
-	x.DatabaseTZ = time.UTC
-	x.TZLocation = time.UTC
-
+	// need to get engine to clean db before we init
+	engine, err := xorm.NewEngine(dbType, sec.Key("connection_string").String())
 	if err != nil {
 	if err != nil {
 		t.Fatalf("Failed to init test database: %v", err)
 		t.Fatalf("Failed to init test database: %v", err)
 	}
 	}
 
 
-	dialect = migrator.NewDialect(x)
-
-	err = dialect.CleanDB()
-	if err != nil {
+	dialect = migrator.NewDialect(engine)
+	if err := dialect.CleanDB(); err != nil {
 		t.Fatalf("Failed to clean test db %v", err)
 		t.Fatalf("Failed to clean test db %v", err)
 	}
 	}
 
 
-	if err := SetEngine(x); err != nil {
-		t.Fatal(err)
+	if err := sqlstore.Init(); err != nil {
+		t.Fatalf("Failed to init test database: %v", err)
 	}
 	}
 
 
-	// x.ShowSQL()
+	//// sqlstore.engine.DatabaseTZ = time.UTC
+	//// sqlstore.engine.TZLocation = time.UTC
 
 
-	return x
+	return sqlstore
 }
 }
 
 
 func IsTestDbMySql() bool {
 func IsTestDbMySql() bool {
@@ -289,3 +298,15 @@ func IsTestDbPostgres() bool {
 
 
 	return false
 	return false
 }
 }
+
+type DatabaseConfig struct {
+	Type, Host, Name, User, Pwd, Path, SslMode string
+	CaCertPath                                 string
+	ClientKeyPath                              string
+	ClientCertPath                             string
+	ServerCertName                             string
+	ConnectionString                           string
+	MaxOpenConn                                int
+	MaxIdleConn                                int
+	ConnMaxLifetime                            int
+}

+ 3 - 1
pkg/setting/setting.go

@@ -495,7 +495,9 @@ func validateStaticRootPath() error {
 }
 }
 
 
 func NewCfg() *Cfg {
 func NewCfg() *Cfg {
-	return &Cfg{}
+	return &Cfg{
+		Raw: ini.Empty(),
+	}
 }
 }
 
 
 func (cfg *Cfg) Load(args *CommandLineArgs) error {
 func (cfg *Cfg) Load(args *CommandLineArgs) error {

+ 5 - 5
public/app/core/utils/kbn.ts

@@ -989,17 +989,17 @@ kbn.getUnitFormats = function() {
     {
     {
       text: 'velocity',
       text: 'velocity',
       submenu: [
       submenu: [
-        { text: 'm/s', value: 'velocityms' },
-        { text: 'km/h', value: 'velocitykmh' },
-        { text: 'mph', value: 'velocitymph' },
+        { text: 'metres/second (m/s)', value: 'velocityms' },
+        { text: 'kilometers/hour (km/h)', value: 'velocitykmh' },
+        { text: 'miles/hour (mph)', value: 'velocitymph' },
         { text: 'knot (kn)', value: 'velocityknot' },
         { text: 'knot (kn)', value: 'velocityknot' },
       ],
       ],
     },
     },
     {
     {
       text: 'volume',
       text: 'volume',
       submenu: [
       submenu: [
-        { text: 'millilitre', value: 'mlitre' },
-        { text: 'litre', value: 'litre' },
+        { text: 'millilitre (mL)', value: 'mlitre' },
+        { text: 'litre (L)', value: 'litre' },
         { text: 'cubic metre', value: 'm3' },
         { text: 'cubic metre', value: 'm3' },
         { text: 'Normal cubic metre', value: 'Nm3' },
         { text: 'Normal cubic metre', value: 'Nm3' },
         { text: 'cubic decimetre', value: 'dm3' },
         { text: 'cubic decimetre', value: 'dm3' },

+ 1 - 1
public/app/features/panel/metrics_panel_ctrl.ts

@@ -312,7 +312,7 @@ class MetricsPanelCtrl extends PanelCtrl {
 
 
   getAdditionalMenuItems() {
   getAdditionalMenuItems() {
     const items = [];
     const items = [];
-    if (this.datasource.supportsExplore) {
+    if (this.datasource && this.datasource.supportsExplore) {
       items.push({
       items.push({
         text: 'Explore',
         text: 'Explore',
         click: 'ctrl.explore();',
         click: 'ctrl.explore();',

+ 65 - 0
public/app/features/panel/specs/metrics_panel_ctrl.jest.ts

@@ -0,0 +1,65 @@
+jest.mock('app/core/core', () => ({}));
+
+import { MetricsPanelCtrl } from '../metrics_panel_ctrl';
+import q from 'q';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+
+describe('MetricsPanelCtrl', () => {
+  let ctrl;
+
+  beforeEach(() => {
+    ctrl = setupController();
+  });
+
+  describe('when getting additional menu items', () => {
+    let additionalItems;
+
+    describe('and has no datasource set', () => {
+      beforeEach(() => {
+        additionalItems = ctrl.getAdditionalMenuItems();
+      });
+
+      it('should not return any items', () => {
+        expect(additionalItems.length).toBe(0);
+      });
+    });
+
+    describe('and has datasource set that supports explore', () => {
+      beforeEach(() => {
+        ctrl.datasource = { supportsExplore: true };
+        additionalItems = ctrl.getAdditionalMenuItems();
+      });
+
+      it('should not return any items', () => {
+        expect(additionalItems.length).toBe(1);
+      });
+    });
+  });
+});
+
+function setupController() {
+  const injectorStub = {
+    get: type => {
+      switch (type) {
+        case '$q': {
+          return q;
+        }
+        default: {
+          return jest.fn();
+        }
+      }
+    },
+  };
+
+  const scope = {
+    panel: { events: [] },
+    appEvent: jest.fn(),
+    onAppEvent: jest.fn(),
+    $on: jest.fn(),
+    colors: [],
+  };
+
+  MetricsPanelCtrl.prototype.panel = new PanelModel({ type: 'test' });
+
+  return new MetricsPanelCtrl(scope, injectorStub);
+}

+ 5 - 14
public/app/plugins/datasource/influxdb/response_parser.ts

@@ -11,23 +11,14 @@ export default class ResponseParser {
       return [];
       return [];
     }
     }
 
 
+    var influxdb11format = query.toLowerCase().indexOf('show tag values') >= 0;
+
     var res = {};
     var res = {};
     _.each(influxResults.series, serie => {
     _.each(influxResults.series, serie => {
       _.each(serie.values, value => {
       _.each(serie.values, value => {
         if (_.isArray(value)) {
         if (_.isArray(value)) {
-          // In general, there are 2 possible shapes for the returned value.
-          // The first one is a two-element array,
-          // where the first element is somewhat a metadata value:
-          // the tag name for SHOW TAG VALUES queries,
-          // the time field for SELECT queries, etc.
-          // The second shape is an one-element array,
-          // that is containing an immediate value.
-          // For example, SHOW FIELD KEYS queries return such shape.
-          // Note, pre-0.11 versions return
-          // the second shape for SHOW TAG VALUES queries
-          // (while the newer versions—first).
-          if (value[1] !== undefined) {
-            addUnique(res, value[1]);
+          if (influxdb11format) {
+            addUnique(res, value[1] || value[0]);
           } else {
           } else {
             addUnique(res, value[0]);
             addUnique(res, value[0]);
           }
           }
@@ -38,7 +29,7 @@ export default class ResponseParser {
     });
     });
 
 
     return _.map(res, value => {
     return _.map(res, value => {
-      return { text: value.toString() };
+      return { text: value };
     });
     });
   }
   }
 }
 }

+ 0 - 26
public/app/plugins/datasource/influxdb/specs/response_parser.jest.ts

@@ -85,32 +85,6 @@ describe('influxdb response parser', () => {
     });
     });
   });
   });
 
 
-  describe('SELECT response', () => {
-    var query = 'SELECT "usage_iowait" FROM "cpu" LIMIT 10';
-    var response = {
-      results: [
-        {
-          series: [
-            {
-              name: 'cpu',
-              columns: ['time', 'usage_iowait'],
-              values: [[1488465190006040638, 0.0], [1488465190006040638, 15.0], [1488465190006040638, 20.2]],
-            },
-          ],
-        },
-      ],
-    };
-
-    var result = parser.parse(query, response);
-
-    it('should return second column', () => {
-      expect(_.size(result)).toBe(3);
-      expect(result[0].text).toBe('0');
-      expect(result[1].text).toBe('15');
-      expect(result[2].text).toBe('20.2');
-    });
-  });
-
   describe('SHOW FIELD response', () => {
   describe('SHOW FIELD response', () => {
     var query = 'SHOW FIELD KEYS FROM "cpu"';
     var query = 'SHOW FIELD KEYS FROM "cpu"';
     describe('response from 0.10.0', () => {
     describe('response from 0.10.0', () => {

+ 36 - 16
public/app/plugins/datasource/prometheus/datasource.ts

@@ -27,6 +27,7 @@ export class PrometheusDatasource {
   withCredentials: any;
   withCredentials: any;
   metricsNameCache: any;
   metricsNameCache: any;
   interval: string;
   interval: string;
+  queryTimeout: string;
   httpMethod: string;
   httpMethod: string;
   resultTransformer: ResultTransformer;
   resultTransformer: ResultTransformer;
 
 
@@ -42,6 +43,7 @@ export class PrometheusDatasource {
     this.basicAuth = instanceSettings.basicAuth;
     this.basicAuth = instanceSettings.basicAuth;
     this.withCredentials = instanceSettings.withCredentials;
     this.withCredentials = instanceSettings.withCredentials;
     this.interval = instanceSettings.jsonData.timeInterval || '15s';
     this.interval = instanceSettings.jsonData.timeInterval || '15s';
+    this.queryTimeout = instanceSettings.jsonData.queryTimeout;
     this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
     this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
     this.resultTransformer = new ResultTransformer(templateSrv);
     this.resultTransformer = new ResultTransformer(templateSrv);
   }
   }
@@ -107,10 +109,18 @@ export class PrometheusDatasource {
     return this.templateSrv.variableExists(target.expr);
     return this.templateSrv.variableExists(target.expr);
   }
   }
 
 
+  clampRange(start, end, step) {
+    const clampedEnd = Math.ceil(end / step) * step;
+    const clampedRange = Math.floor((end - start) / step) * step;
+    return {
+      end: clampedEnd,
+      start: clampedEnd - clampedRange,
+    };
+  }
+
   query(options) {
   query(options) {
     var start = this.getPrometheusTime(options.range.from, false);
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
     var end = this.getPrometheusTime(options.range.to, true);
-    var range = Math.ceil(end - start);
 
 
     var queries = [];
     var queries = [];
     var activeTargets = [];
     var activeTargets = [];
@@ -123,7 +133,7 @@ export class PrometheusDatasource {
       }
       }
 
 
       activeTargets.push(target);
       activeTargets.push(target);
-      queries.push(this.createQuery(target, options, range));
+      queries.push(this.createQuery(target, options, start, end));
     }
     }
 
 
     // No valid targets, return the empty result to save a round trip.
     // No valid targets, return the empty result to save a round trip.
@@ -133,7 +143,7 @@ export class PrometheusDatasource {
 
 
     var allQueryPromise = _.map(queries, query => {
     var allQueryPromise = _.map(queries, query => {
       if (!query.instant) {
       if (!query.instant) {
-        return this.performTimeSeriesQuery(query, start, end);
+        return this.performTimeSeriesQuery(query, query.start, query.end);
       } else {
       } else {
         return this.performInstantQuery(query, end);
         return this.performInstantQuery(query, end);
       }
       }
@@ -147,7 +157,8 @@ export class PrometheusDatasource {
           throw response.error;
           throw response.error;
         }
         }
 
 
-        let transformerOptions = {
+        // Keeping original start/end for transformers
+        const transformerOptions = {
           format: activeTargets[index].format,
           format: activeTargets[index].format,
           step: queries[index].step,
           step: queries[index].step,
           legendFormat: activeTargets[index].legendFormat,
           legendFormat: activeTargets[index].legendFormat,
@@ -165,9 +176,10 @@ export class PrometheusDatasource {
     });
     });
   }
   }
 
 
-  createQuery(target, options, range) {
+  createQuery(target, options, start, end) {
     var query: any = {};
     var query: any = {};
     query.instant = target.instant;
     query.instant = target.instant;
+    var range = Math.ceil(end - start);
 
 
     var interval = kbn.interval_to_seconds(options.interval);
     var interval = kbn.interval_to_seconds(options.interval);
     // Minimum interval ("Min step"), if specified for the query. or same as interval otherwise
     // Minimum interval ("Min step"), if specified for the query. or same as interval otherwise
@@ -191,6 +203,12 @@ export class PrometheusDatasource {
     // Only replace vars in expression after having (possibly) updated interval vars
     // Only replace vars in expression after having (possibly) updated interval vars
     query.expr = this.templateSrv.replace(target.expr, scopedVars, this.interpolateQueryExpr);
     query.expr = this.templateSrv.replace(target.expr, scopedVars, this.interpolateQueryExpr);
     query.requestId = options.panelId + target.refId;
     query.requestId = options.panelId + target.refId;
+
+    // Align query interval with step
+    const adjusted = this.clampRange(start, end, query.step);
+    query.start = adjusted.start;
+    query.end = adjusted.end;
+
     return query;
     return query;
   }
   }
 
 
@@ -215,6 +233,9 @@ export class PrometheusDatasource {
       end: end,
       end: end,
       step: query.step,
       step: query.step,
     };
     };
+    if (this.queryTimeout) {
+      data['timeout'] = this.queryTimeout;
+    }
     return this._request(url, data, { requestId: query.requestId });
     return this._request(url, data, { requestId: query.requestId });
   }
   }
 
 
@@ -224,6 +245,9 @@ export class PrometheusDatasource {
       query: query.expr,
       query: query.expr,
       time: time,
       time: time,
     };
     };
+    if (this.queryTimeout) {
+      data['timeout'] = this.queryTimeout;
+    }
     return this._request(url, data, { requestId: query.requestId });
     return this._request(url, data, { requestId: query.requestId });
   }
   }
 
 
@@ -270,22 +294,18 @@ export class PrometheusDatasource {
       return this.$q.when([]);
       return this.$q.when([]);
     }
     }
 
 
-    var interpolated = this.templateSrv.replace(expr, {}, this.interpolateQueryExpr);
-
-    var step = '60s';
-    if (annotation.step) {
-      step = this.templateSrv.replace(annotation.step);
-    }
-
+    var step = annotation.step || '60s';
     var start = this.getPrometheusTime(options.range.from, false);
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
     var end = this.getPrometheusTime(options.range.to, true);
-    var query = {
-      expr: interpolated,
-      step: this.adjustInterval(kbn.interval_to_seconds(step), 0, Math.ceil(end - start), 1) + 's',
+    // Unsetting min interval
+    const queryOptions = {
+      ...options,
+      interval: '0s',
     };
     };
+    const query = this.createQuery({ expr, interval: step }, queryOptions, start, end);
 
 
     var self = this;
     var self = this;
-    return this.performTimeSeriesQuery(query, start, end).then(function(results) {
+    return this.performTimeSeriesQuery(query, query.start, query.end).then(function(results) {
       var eventList = [];
       var eventList = [];
       tagKeys = tagKeys.split(',');
       tagKeys = tagKeys.split(',');
 
 

+ 19 - 9
public/app/plugins/datasource/prometheus/partials/config.html

@@ -2,15 +2,25 @@
 </datasource-http-settings>
 </datasource-http-settings>
 
 
 <div class="gf-form-group">
 <div class="gf-form-group">
-	<div class="gf-form-inline">
-		<div class="gf-form">
-			<span class="gf-form-label width-8">Scrape interval</span>
-			<input type="text" class="gf-form-input width-8" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="15s"></input>
-			<info-popover mode="right-absolute">
-                Set this to your global scrape interval defined in your Prometheus config file. This will be used as a lower limit for
-                the Prometheus step query parameter.
-			</info-popover>
-		</div>
+  <div class="gf-form-inline">
+    <div class="gf-form">
+      <span class="gf-form-label width-8">Scrape interval</span>
+      <input type="text" class="gf-form-input width-8" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="15s"></input>
+      <info-popover mode="right-absolute">
+        Set this to your global scrape interval defined in your Prometheus config file. This will be used as a lower limit for the
+        Prometheus step query parameter.
+      </info-popover>
+    </div>
+  </div>
+
+  <div class="gf-form-inline">
+    <div class="gf-form">
+      <span class="gf-form-label width-8">Query timeout</span>
+      <input type="text" class="gf-form-input width-8" ng-model="ctrl.current.jsonData.queryTimeout" spellcheck='false' placeholder="60s"></input>
+      <info-popover mode="right-absolute">
+        Set the Prometheus query timeout.
+      </info-popover>
+    </div>
   </div>
   </div>
 
 
   <div class="gf-form">
   <div class="gf-form">

+ 5 - 4
public/app/plugins/datasource/prometheus/partials/query.editor.html

@@ -14,8 +14,8 @@
         data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
         data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
       </input>
       </input>
       <info-popover mode="right-absolute">
       <info-popover mode="right-absolute">
-        Controls the name of the time series, using name or pattern. For example <span ng-non-bindable>{{hostname}}</span> will be replaced with label value for
-        the label hostname.
+        Controls the name of the time series, using name or pattern. For example
+        <span ng-non-bindable>{{hostname}}</span> will be replaced with label value for the label hostname.
       </info-popover>
       </info-popover>
     </div>
     </div>
 
 
@@ -25,7 +25,8 @@
         placeholder="{{ctrl.panelCtrl.interval}}" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.refreshMetricData()"
         placeholder="{{ctrl.panelCtrl.interval}}" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.refreshMetricData()"
       />
       />
       <info-popover mode="right-absolute">
       <info-popover mode="right-absolute">
-        Leave blank for auto handling based on time range and panel width
+        Leave blank for auto handling based on time range and panel width. Note that the actual dates used in the query will be adjusted
+        to a multiple of the interval step.
       </info-popover>
       </info-popover>
     </div>
     </div>
 
 
@@ -57,4 +58,4 @@
       <div class="gf-form-label gf-form-label--grow"></div>
       <div class="gf-form-label gf-form-label--grow"></div>
     </div>
     </div>
   </div>
   </div>
-</query-editor-row>
+</query-editor-row>

+ 108 - 76
public/app/plugins/datasource/prometheus/specs/datasource_specs.ts

@@ -4,6 +4,12 @@ import $ from 'jquery';
 import helpers from 'test/specs/helpers';
 import helpers from 'test/specs/helpers';
 import { PrometheusDatasource } from '../datasource';
 import { PrometheusDatasource } from '../datasource';
 
 
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+
+const time = ({ hours = 0, seconds = 0, minutes = 0 }) => moment(hours * HOUR + minutes * MINUTE + seconds * SECOND);
+
 describe('PrometheusDatasource', function() {
 describe('PrometheusDatasource', function() {
   var ctx = new helpers.ServiceTestContext();
   var ctx = new helpers.ServiceTestContext();
   var instanceSettings = {
   var instanceSettings = {
@@ -29,18 +35,16 @@ describe('PrometheusDatasource', function() {
       $httpBackend.when('GET', /\.html$/).respond('');
       $httpBackend.when('GET', /\.html$/).respond('');
     })
     })
   );
   );
-
   describe('When querying prometheus with one target using query editor target spec', function() {
   describe('When querying prometheus with one target using query editor target spec', function() {
     var results;
     var results;
-    var urlExpected =
-      'proxied/api/v1/query_range?query=' +
-      encodeURIComponent('test{job="testjob"}') +
-      '&start=1443438675&end=1443460275&step=60';
     var query = {
     var query = {
-      range: { from: moment(1443438674760), to: moment(1443460274760) },
+      range: { from: time({ seconds: 63 }), to: time({ seconds: 183 }) },
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
       interval: '60s',
       interval: '60s',
     };
     };
+    // Interval alignment with step
+    var urlExpected =
+      'proxied/api/v1/query_range?query=' + encodeURIComponent('test{job="testjob"}') + '&start=120&end=240&step=60';
     var response = {
     var response = {
       status: 'success',
       status: 'success',
       data: {
       data: {
@@ -48,7 +52,7 @@ describe('PrometheusDatasource', function() {
         result: [
         result: [
           {
           {
             metric: { __name__: 'test', job: 'testjob' },
             metric: { __name__: 'test', job: 'testjob' },
-            values: [[1443454528, '3846']],
+            values: [[60, '3846']],
           },
           },
         ],
         ],
       },
       },
@@ -70,8 +74,8 @@ describe('PrometheusDatasource', function() {
   });
   });
   describe('When querying prometheus with one target which return multiple series', function() {
   describe('When querying prometheus with one target which return multiple series', function() {
     var results;
     var results;
-    var start = 1443438675;
-    var end = 1443460275;
+    var start = 60;
+    var end = 360;
     var step = 60;
     var step = 60;
     var urlExpected =
     var urlExpected =
       'proxied/api/v1/query_range?query=' +
       'proxied/api/v1/query_range?query=' +
@@ -83,7 +87,7 @@ describe('PrometheusDatasource', function() {
       '&step=' +
       '&step=' +
       step;
       step;
     var query = {
     var query = {
-      range: { from: moment(1443438674760), to: moment(1443460274760) },
+      range: { from: time({ seconds: start }), to: time({ seconds: end }) },
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
       interval: '60s',
       interval: '60s',
     };
     };
@@ -139,9 +143,9 @@ describe('PrometheusDatasource', function() {
   });
   });
   describe('When querying prometheus with one target and instant = true', function() {
   describe('When querying prometheus with one target and instant = true', function() {
     var results;
     var results;
-    var urlExpected = 'proxied/api/v1/query?query=' + encodeURIComponent('test{job="testjob"}') + '&time=1443460275';
+    var urlExpected = 'proxied/api/v1/query?query=' + encodeURIComponent('test{job="testjob"}') + '&time=123';
     var query = {
     var query = {
-      range: { from: moment(1443438674760), to: moment(1443460274760) },
+      range: { from: time({ seconds: 63 }), to: time({ seconds: 123 }) },
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series', instant: true }],
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series', instant: true }],
       interval: '60s',
       interval: '60s',
     };
     };
@@ -152,7 +156,7 @@ describe('PrometheusDatasource', function() {
         result: [
         result: [
           {
           {
             metric: { __name__: 'test', job: 'testjob' },
             metric: { __name__: 'test', job: 'testjob' },
-            value: [1443454528, '3846'],
+            value: [123, '3846'],
           },
           },
         ],
         ],
       },
       },
@@ -177,7 +181,7 @@ describe('PrometheusDatasource', function() {
     var urlExpected =
     var urlExpected =
       'proxied/api/v1/query_range?query=' +
       'proxied/api/v1/query_range?query=' +
       encodeURIComponent('ALERTS{alertstate="firing"}') +
       encodeURIComponent('ALERTS{alertstate="firing"}') +
-      '&start=1443438675&end=1443460275&step=60s';
+      '&start=120&end=180&step=60';
     var options = {
     var options = {
       annotation: {
       annotation: {
         expr: 'ALERTS{alertstate="firing"}',
         expr: 'ALERTS{alertstate="firing"}',
@@ -186,8 +190,8 @@ describe('PrometheusDatasource', function() {
         textFormat: '{{instance}}',
         textFormat: '{{instance}}',
       },
       },
       range: {
       range: {
-        from: moment(1443438674760),
-        to: moment(1443460274760),
+        from: time({ seconds: 63 }),
+        to: time({ seconds: 123 }),
       },
       },
     };
     };
     var response = {
     var response = {
@@ -203,7 +207,7 @@ describe('PrometheusDatasource', function() {
               instance: 'testinstance',
               instance: 'testinstance',
               job: 'testjob',
               job: 'testjob',
             },
             },
-            values: [[1443454528, '1']],
+            values: [[123, '1']],
           },
           },
         ],
         ],
       },
       },
@@ -221,15 +225,15 @@ describe('PrometheusDatasource', function() {
       expect(results[0].tags).to.contain('testjob');
       expect(results[0].tags).to.contain('testjob');
       expect(results[0].title).to.be('InstanceDown');
       expect(results[0].title).to.be('InstanceDown');
       expect(results[0].text).to.be('testinstance');
       expect(results[0].text).to.be('testinstance');
-      expect(results[0].time).to.be(1443454528 * 1000);
+      expect(results[0].time).to.be(123 * 1000);
     });
     });
   });
   });
 
 
   describe('When resultFormat is table and instant = true', function() {
   describe('When resultFormat is table and instant = true', function() {
     var results;
     var results;
-    var urlExpected = 'proxied/api/v1/query?query=' + encodeURIComponent('test{job="testjob"}') + '&time=1443460275';
+    var urlExpected = 'proxied/api/v1/query?query=' + encodeURIComponent('test{job="testjob"}') + '&time=123';
     var query = {
     var query = {
-      range: { from: moment(1443438674760), to: moment(1443460274760) },
+      range: { from: time({ seconds: 63 }), to: time({ seconds: 123 }) },
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series', instant: true }],
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series', instant: true }],
       interval: '60s',
       interval: '60s',
     };
     };
@@ -240,7 +244,7 @@ describe('PrometheusDatasource', function() {
         result: [
         result: [
           {
           {
             metric: { __name__: 'test', job: 'testjob' },
             metric: { __name__: 'test', job: 'testjob' },
-            value: [1443454528, '3846'],
+            value: [123, '3846'],
           },
           },
         ],
         ],
       },
       },
@@ -270,8 +274,8 @@ describe('PrometheusDatasource', function() {
 
 
     it('should be min interval when greater than auto interval', function() {
     it('should be min interval when greater than auto interval', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -280,7 +284,7 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '5s',
         interval: '5s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1443460275&step=10';
+      var urlExpected = 'proxied/api/v1/query_range?query=test&start=60&end=420&step=10';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -288,12 +292,12 @@ describe('PrometheusDatasource', function() {
 
 
     it('step should never go below 1', function() {
     it('step should never go below 1', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1508318768202), to: moment(1508318770118) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [{ expr: 'test' }],
         targets: [{ expr: 'test' }],
         interval: '100ms',
         interval: '100ms',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test&start=1508318769&end=1508318771&step=1';
+      var urlExpected = 'proxied/api/v1/query_range?query=test&start=60&end=420&step=1';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -301,8 +305,8 @@ describe('PrometheusDatasource', function() {
 
 
     it('should be auto interval when greater than min interval', function() {
     it('should be auto interval when greater than min interval', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -311,7 +315,7 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '10s',
         interval: '10s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1443460275&step=10';
+      var urlExpected = 'proxied/api/v1/query_range?query=test&start=60&end=420&step=10';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -319,19 +323,21 @@ describe('PrometheusDatasource', function() {
     it('should result in querying fewer than 11000 data points', function() {
     it('should result in querying fewer than 11000 data points', function() {
       var query = {
       var query = {
         // 6 hour range
         // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        range: { from: time({ hours: 1 }), to: time({ hours: 7 }) },
         targets: [{ expr: 'test' }],
         targets: [{ expr: 'test' }],
         interval: '1s',
         interval: '1s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1443460275&step=2';
+      var end = 7 * 60 * 60;
+      var start = 60 * 60;
+      var urlExpected = 'proxied/api/v1/query_range?query=test&start=' + start + '&end=' + end + '&step=2';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     });
     it('should not apply min interval when interval * intervalFactor greater', function() {
     it('should not apply min interval when interval * intervalFactor greater', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -341,15 +347,16 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '5s',
         interval: '5s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1443460275&step=50';
+      // times get rounded up to interval
+      var urlExpected = 'proxied/api/v1/query_range?query=test&start=100&end=450&step=50';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     });
     it('should apply min interval when interval * intervalFactor smaller', function() {
     it('should apply min interval when interval * intervalFactor smaller', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -359,15 +366,15 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '5s',
         interval: '5s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1443460275&step=15';
+      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=60&end=420&step=15';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     });
     it('should apply intervalFactor to auto interval when greater', function() {
     it('should apply intervalFactor to auto interval when greater', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -377,7 +384,8 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '10s',
         interval: '10s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1443460275&step=100';
+      // times get rounded up to interval
+      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=200&end=500&step=100';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -385,7 +393,7 @@ describe('PrometheusDatasource', function() {
     it('should not not be affected by the 11000 data points limit when large enough', function() {
     it('should not not be affected by the 11000 data points limit when large enough', function() {
       var query = {
       var query = {
         // 1 week range
         // 1 week range
-        range: { from: moment(1443438674760), to: moment(1444043474760) },
+        range: { from: time({}), to: time({ hours: 7 * 24 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -394,7 +402,9 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '10s',
         interval: '10s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1444043475&step=100';
+      var end = 7 * 24 * 60 * 60;
+      var start = 0;
+      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=' + start + '&end=' + end + '&step=100';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -402,7 +412,7 @@ describe('PrometheusDatasource', function() {
     it('should be determined by the 11000 data points limit when too small', function() {
     it('should be determined by the 11000 data points limit when too small', function() {
       var query = {
       var query = {
         // 1 week range
         // 1 week range
-        range: { from: moment(1443438674760), to: moment(1444043474760) },
+        range: { from: time({}), to: time({ hours: 7 * 24 }) },
         targets: [
         targets: [
           {
           {
             expr: 'test',
             expr: 'test',
@@ -411,12 +421,15 @@ describe('PrometheusDatasource', function() {
         ],
         ],
         interval: '5s',
         interval: '5s',
       };
       };
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=1443438675&end=1444043475&step=60';
+      var end = 7 * 24 * 60 * 60;
+      var start = 0;
+      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=' + start + '&end=' + end + '&step=60';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     });
   });
   });
+
   describe('The __interval and __interval_ms template variables', function() {
   describe('The __interval and __interval_ms template variables', function() {
     var response = {
     var response = {
       status: 'success',
       status: 'success',
@@ -428,8 +441,8 @@ describe('PrometheusDatasource', function() {
 
 
     it('should be unchanged when auto interval is greater than min interval', function() {
     it('should be unchanged when auto interval is greater than min interval', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'rate(test[$__interval])',
             expr: 'rate(test[$__interval])',
@@ -443,9 +456,7 @@ describe('PrometheusDatasource', function() {
         },
         },
       };
       };
       var urlExpected =
       var urlExpected =
-        'proxied/api/v1/query_range?query=' +
-        encodeURIComponent('rate(test[10s])') +
-        '&start=1443438675&end=1443460275&step=10';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[10s])') + '&start=60&end=420&step=10';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -457,8 +468,8 @@ describe('PrometheusDatasource', function() {
     });
     });
     it('should be min interval when it is greater than auto interval', function() {
     it('should be min interval when it is greater than auto interval', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'rate(test[$__interval])',
             expr: 'rate(test[$__interval])',
@@ -472,9 +483,7 @@ describe('PrometheusDatasource', function() {
         },
         },
       };
       };
       var urlExpected =
       var urlExpected =
-        'proxied/api/v1/query_range?query=' +
-        encodeURIComponent('rate(test[10s])') +
-        '&start=1443438675&end=1443460275&step=10';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[10s])') + '&start=60&end=420&step=10';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -486,8 +495,8 @@ describe('PrometheusDatasource', function() {
     });
     });
     it('should account for intervalFactor', function() {
     it('should account for intervalFactor', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'rate(test[$__interval])',
             expr: 'rate(test[$__interval])',
@@ -502,9 +511,7 @@ describe('PrometheusDatasource', function() {
         },
         },
       };
       };
       var urlExpected =
       var urlExpected =
-        'proxied/api/v1/query_range?query=' +
-        encodeURIComponent('rate(test[100s])') +
-        '&start=1443438675&end=1443460275&step=100';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[100s])') + '&start=200&end=500&step=100';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -516,8 +523,8 @@ describe('PrometheusDatasource', function() {
     });
     });
     it('should be interval * intervalFactor when greater than min interval', function() {
     it('should be interval * intervalFactor when greater than min interval', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'rate(test[$__interval])',
             expr: 'rate(test[$__interval])',
@@ -532,9 +539,7 @@ describe('PrometheusDatasource', function() {
         },
         },
       };
       };
       var urlExpected =
       var urlExpected =
-        'proxied/api/v1/query_range?query=' +
-        encodeURIComponent('rate(test[50s])') +
-        '&start=1443438675&end=1443460275&step=50';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[50s])') + '&start=100&end=450&step=50';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -546,8 +551,8 @@ describe('PrometheusDatasource', function() {
     });
     });
     it('should be min interval when greater than interval * intervalFactor', function() {
     it('should be min interval when greater than interval * intervalFactor', function() {
       var query = {
       var query = {
-        // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        // 6 minute range
+        range: { from: time({ minutes: 1 }), to: time({ minutes: 7 }) },
         targets: [
         targets: [
           {
           {
             expr: 'rate(test[$__interval])',
             expr: 'rate(test[$__interval])',
@@ -562,9 +567,7 @@ describe('PrometheusDatasource', function() {
         },
         },
       };
       };
       var urlExpected =
       var urlExpected =
-        'proxied/api/v1/query_range?query=' +
-        encodeURIComponent('rate(test[15s])') +
-        '&start=1443438675&end=1443460275&step=15';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[15s])') + '&start=60&end=420&step=15';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -577,7 +580,7 @@ describe('PrometheusDatasource', function() {
     it('should be determined by the 11000 data points limit, accounting for intervalFactor', function() {
     it('should be determined by the 11000 data points limit, accounting for intervalFactor', function() {
       var query = {
       var query = {
         // 1 week range
         // 1 week range
-        range: { from: moment(1443438674760), to: moment(1444043474760) },
+        range: { from: time({}), to: time({ hours: 7 * 24 }) },
         targets: [
         targets: [
           {
           {
             expr: 'rate(test[$__interval])',
             expr: 'rate(test[$__interval])',
@@ -590,10 +593,16 @@ describe('PrometheusDatasource', function() {
           __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
           __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
         },
         },
       };
       };
+      var end = 7 * 24 * 60 * 60;
+      var start = 0;
       var urlExpected =
       var urlExpected =
         'proxied/api/v1/query_range?query=' +
         'proxied/api/v1/query_range?query=' +
         encodeURIComponent('rate(test[60s])') +
         encodeURIComponent('rate(test[60s])') +
-        '&start=1443438675&end=1444043475&step=60';
+        '&start=' +
+        start +
+        '&end=' +
+        end +
+        '&step=60';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -604,6 +613,29 @@ describe('PrometheusDatasource', function() {
       expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
       expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
     });
     });
   });
   });
+
+  describe('Step alignment of intervals', function() {
+    it('does not modify already aligned intervals with perfect step', function() {
+      const range = ctx.ds.clampRange(0, 3, 3);
+      expect(range.start).to.be(0);
+      expect(range.end).to.be(3);
+    });
+    it('does modify end-aligned intervals to reflect number of steps possible', function() {
+      const range = ctx.ds.clampRange(1, 6, 3);
+      expect(range.start).to.be(3);
+      expect(range.end).to.be(6);
+    });
+    it('does align intervals that are a multiple of steps', function() {
+      const range = ctx.ds.clampRange(1, 4, 3);
+      expect(range.start).to.be(3);
+      expect(range.end).to.be(6);
+    });
+    it('does align intervals that are not a multiple of steps', function() {
+      const range = ctx.ds.clampRange(1, 5, 3);
+      expect(range.start).to.be(3);
+      expect(range.end).to.be(6);
+    });
+  });
 });
 });
 
 
 describe('PrometheusDatasource for POST', function() {
 describe('PrometheusDatasource for POST', function() {
@@ -635,12 +667,12 @@ describe('PrometheusDatasource for POST', function() {
     var urlExpected = 'proxied/api/v1/query_range';
     var urlExpected = 'proxied/api/v1/query_range';
     var dataExpected = $.param({
     var dataExpected = $.param({
       query: 'test{job="testjob"}',
       query: 'test{job="testjob"}',
-      start: 1443438675,
-      end: 1443460275,
+      start: 2 * 60,
+      end: 3 * 60,
       step: 60,
       step: 60,
     });
     });
     var query = {
     var query = {
-      range: { from: moment(1443438674760), to: moment(1443460274760) },
+      range: { from: time({ minutes: 1, seconds: 3 }), to: time({ minutes: 2, seconds: 3 }) },
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
       targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
       interval: '60s',
       interval: '60s',
     };
     };
@@ -651,7 +683,7 @@ describe('PrometheusDatasource for POST', function() {
         result: [
         result: [
           {
           {
             metric: { __name__: 'test', job: 'testjob' },
             metric: { __name__: 'test', job: 'testjob' },
-            values: [[1443454528, '3846']],
+            values: [[2 * 60, '3846']],
           },
           },
         ],
         ],
       },
       },

+ 2 - 2
public/app/plugins/panel/graph/graph.ts

@@ -674,7 +674,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
           return;
           return;
         }
         }
 
 
-        if ((ranges.ctrlKey || ranges.metaKey) && dashboard.meta.canEdit) {
+        if ((ranges.ctrlKey || ranges.metaKey) && (dashboard.meta.canEdit || dashboard.meta.canMakeEditable)) {
           // Add annotation
           // Add annotation
           setTimeout(() => {
           setTimeout(() => {
             eventManager.updateTime(ranges.xaxis);
             eventManager.updateTime(ranges.xaxis);
@@ -695,7 +695,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
           return;
           return;
         }
         }
 
 
-        if ((pos.ctrlKey || pos.metaKey) && dashboard.meta.canEdit) {
+        if ((pos.ctrlKey || pos.metaKey) && (dashboard.meta.canEdit || dashboard.meta.canMakeEditable)) {
           // Skip if range selected (added in "plotselected" event handler)
           // Skip if range selected (added in "plotselected" event handler)
           let isRangeSelection = pos.x !== pos.x1;
           let isRangeSelection = pos.x !== pos.x1;
           if (!isRangeSelection) {
           if (!isRangeSelection) {

+ 4 - 0
public/app/plugins/panel/graph/legend.ts

@@ -287,6 +287,10 @@ module.directive('graphLegend', function(popoverSrv, $timeout) {
           destroyScrollbar();
           destroyScrollbar();
           legendScrollbar = baron(scrollbarParams);
           legendScrollbar = baron(scrollbarParams);
         }
         }
+
+        // #11830 - compensates for Firefox scrollbar calculation error in the baron framework
+        scroller[0].style.marginRight = '-' + (scroller[0].offsetWidth - scroller[0].clientWidth) + 'px';
+
         legendScrollbar.scroll();
         legendScrollbar.scroll();
       }
       }
 
 

+ 8 - 0
public/sass/pages/_dashboard.scss

@@ -44,10 +44,18 @@ div.flot-text {
   padding: $panel-padding;
   padding: $panel-padding;
   height: calc(100% - 27px);
   height: calc(100% - 27px);
   position: relative;
   position: relative;
+
   // Fixes scrolling on mobile devices
   // Fixes scrolling on mobile devices
   overflow: auto;
   overflow: auto;
 }
 }
 
 
+// For larger screens, set back to hidden to avoid double scroll bars
+@include media-breakpoint-up(md) {
+  .panel-content {
+    overflow: hidden;
+  }
+}
+
 .panel-title-container {
 .panel-title-container {
   min-height: 9px;
   min-height: 9px;
   cursor: move;
   cursor: move;

+ 40 - 0
scripts/tag_release.sh

@@ -0,0 +1,40 @@
+#/bin/bash
+
+# abort if we get any error
+set -e
+
+_tag=$1
+_branch="$(git rev-parse --abbrev-ref HEAD)"
+
+if [ "${_tag}" == "" ]; then 
+    echo "Missing version param. ex './scripts/tag_release.sh v5.1.1'"
+    exit 1
+fi
+
+if [ "${_branch}" == "master" ]; then 
+    echo "you cannot tag releases from the master branch"
+    echo "please checkout the release branch"
+    echo "ex 'git checkout v5.1.x'"
+    exit 1
+fi
+
+# always make sure to pull latest changes from origin
+echo "pulling latest changes from ${_branch}"
+git pull origin ${_branch}
+
+# create signed tag for latest commit 
+git tag -s "${_tag}" -m "release ${_tag}"
+
+# verify the signed tag
+git tag -v "${_tag}"
+
+echo "Make sure the tag is signed as expected"
+echo "press [y] to push the tags"
+
+read -n 1 confirm
+
+if [ "${confirm}" == "y" ]; then 
+    git push origin "${_branch}" --tags
+else 
+    echo "Abort! "
+fi

+ 24 - 14
scripts/webpack/webpack.dev.js

@@ -31,11 +31,24 @@ const entries = HOT ? {
     vendor: require('./dependencies'),
     vendor: require('./dependencies'),
   };
   };
 
 
+const output = HOT ? {
+  path: path.resolve(__dirname, '../../public/build'),
+  filename: '[name].[hash].js',
+  publicPath: "/public/build/",
+} : {
+    path: path.resolve(__dirname, '../../public/build'),
+    filename: '[name].[hash].js',
+    // Keep publicPath relative for host.com/grafana/ deployments
+    publicPath: "public/build/",
+  };
+
 module.exports = merge(common, {
 module.exports = merge(common, {
   devtool: "cheap-module-source-map",
   devtool: "cheap-module-source-map",
 
 
   entry: entries,
   entry: entries,
 
 
+  output: output,
+
   resolve: {
   resolve: {
     extensions: ['.scss', '.ts', '.tsx', '.es6', '.js', '.json', '.svg', '.woff2', '.png'],
     extensions: ['.scss', '.ts', '.tsx', '.es6', '.js', '.json', '.svg', '.woff2', '.png'],
   },
   },
@@ -66,23 +79,20 @@ module.exports = merge(common, {
       {
       {
         test: /\.tsx?$/,
         test: /\.tsx?$/,
         exclude: /node_modules/,
         exclude: /node_modules/,
-        use: [
-          {
-            loader: 'babel-loader',
-            options: {
+        use: {
+          loader: 'awesome-typescript-loader',
+          options: {
+            useCache: true,
+            useBabel: HOT,
+            babelOptions: {
+              babelrc: false,
               plugins: [
               plugins: [
                 'syntax-dynamic-import',
                 'syntax-dynamic-import',
-                'react-hot-loader/babel',
-              ],
-            },
+                'react-hot-loader/babel'
+              ]
+            }
           },
           },
-          {
-            loader: 'awesome-typescript-loader',
-            options: {
-              useCache: true,
-            },
-          }
-        ]
+        }
       },
       },
       require('./sass.rule.js')({
       require('./sass.rule.js')({
         sourceMap: true, minimize: false, preserveUrl: HOT
         sourceMap: true, minimize: false, preserveUrl: HOT