Explorar el Código

Merge branch 'master' into WPH95-feature/add_es_alerting

Marcus Efraimsson hace 7 años
padre
commit
1324a67cbd
Se han modificado 49 ficheros con 615 adiciones y 403 borrados
  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)
 * **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)
 
 * **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
 
 # list of datasources to insert/update depending
-# whats available in the database
+# what's available in the database
 datasources:
   # <string, required> name of the datasource. Required
 - 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 |
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | 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) |
 | timeField | string | Elastic | Which field that should be used as timestamp |
 | 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 |
 | defaultRegion | string | Cloudwatch | AWS region |
 | 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
 
@@ -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 |
 | tlsClientCert | string | *All* |TLS Client cert 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 |
 | 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)
 - **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)
@@ -24,7 +24,7 @@ weight = 3
 
 {{< 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`.
 
 <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.
 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>
 
 ## Upgrade & Breaking changes

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

@@ -10,7 +10,7 @@ parent = "whatsnew"
 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).
 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)
 * **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)
-* **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)
 * **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)

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

@@ -49,18 +49,15 @@ Content-Type: application/json
   {
     "id": 1,
     "dashboardId": 1,
+    "dashboardUId": "ABcdEFghij"
+    "dashboardSlug": "sensors",
     "panelId": 1,
     "name": "fire place sensor",
-    "message": "Someone is trying to break in through the fire place",
     "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",
-    "evalData": [
-      {
-        "metric": "fire",
-        "tags": null,
-        "value": 5.349999999999999
-      }
-    "newStateDate": "2016-12-25",
+    "evalData": null,
     "executionError": "",
     "url": "http://grafana.com/dashboard/db/sensors"
   }
@@ -88,16 +85,35 @@ Content-Type: application/json
 {
   "id": 1,
   "dashboardId": 1,
+  "dashboardUId": "ABcdEFghij"
+  "dashboardSlug": "sensors",
   "panelId": 1,
   "name": "fire place sensor",
-  "message": "Someone is trying to break in through the fire place",
   "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": "",
   "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
 
 `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
 
-> 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
 
 ## [server]
@@ -717,7 +715,7 @@ Analytics ID here. By default this feature is disabled.
 
 ## [dashboards]
 
-### versions_to_keep (introduced in v5.0)
+### versions_to_keep
 
 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
 ------------ | -------------
-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)
 -->
@@ -27,9 +27,9 @@ installation.
 
 
 ```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 dpkg -i grafana_5.1.2_amd64.deb
+sudo dpkg -i grafana_5.1.3_amd64.deb
 ```
 
 <!-- ## Install Latest Beta

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

@@ -15,7 +15,7 @@ weight = 2
 
 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)
 -->
@@ -28,7 +28,7 @@ installation.
 You can install Grafana using Yum directly.
 
 ```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
@@ -42,15 +42,15 @@ Or install manually using `rpm`.
 #### On CentOS / Fedora / Redhat:
 
 ```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 rpm -Uvh grafana-5.1.2-1.x86_64.rpm
+$ sudo rpm -Uvh grafana-5.1.3-1.x86_64.rpm
 ```
 
 #### On OpenSuse:
 
 ```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

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

@@ -12,7 +12,7 @@ weight = 3
 
 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)

+ 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>
     <figcaption>
        <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>
     </figcaption>
   </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(),
 		})
 
-		sqlstore.NewEngine()
+		engine := &sqlstore.SqlStore{}
+		engine.Cfg = cfg
+		engine.Init()
 
 		if err := command(cmd); err != nil {
 			logger.Errorf("\n%s: ", color.RedString("Error"))

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

@@ -8,7 +8,6 @@ import (
 	"net"
 	"os"
 	"path/filepath"
-	"reflect"
 	"strconv"
 	"time"
 
@@ -16,14 +15,12 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/registry"
-	"github.com/grafana/grafana/pkg/services/dashboards"
 
 	"golang.org/x/sync/errgroup"
 
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
-	"github.com/grafana/grafana/pkg/services/sqlstore"
 	"github.com/grafana/grafana/pkg/setting"
 
 	"github.com/grafana/grafana/pkg/social"
@@ -37,6 +34,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/services/notifications"
 	_ "github.com/grafana/grafana/pkg/services/provisioning"
 	_ "github.com/grafana/grafana/pkg/services/search"
+	_ "github.com/grafana/grafana/pkg/services/sqlstore"
 	_ "github.com/grafana/grafana/pkg/tracing"
 )
 
@@ -70,17 +68,12 @@ func (g *GrafanaServerImpl) Run() error {
 	g.loadConfiguration()
 	g.writePIDFile()
 
-	// initSql
-	sqlstore.NewEngine() // TODO: this should return an error
-	sqlstore.EnsureAdminUser()
-
 	login.Init()
 	social.NewOAuthService()
 
 	serviceGraph := inject.Graph{}
 	serviceGraph.Provide(&inject.Object{Value: bus.GetBus()})
 	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)})
 
 	// self registered services
@@ -88,7 +81,7 @@ func (g *GrafanaServerImpl) Run() error {
 
 	// Add all services to dependency graph
 	for _, service := range services {
-		serviceGraph.Provide(&inject.Object{Value: service})
+		serviceGraph.Provide(&inject.Object{Value: service.Instance})
 	}
 
 	serviceGraph.Provide(&inject.Object{Value: g})
@@ -100,25 +93,27 @@ func (g *GrafanaServerImpl) Run() error {
 
 	// Init & start services
 	for _, service := range services {
-		if registry.IsDisabled(service) {
+		if registry.IsDisabled(service.Instance) {
 			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)
 		}
 	}
 
 	// 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 {
 			continue
 		}
 
-		if registry.IsDisabled(services[index]) {
+		if registry.IsDisabled(descriptor.Instance) {
 			continue
 		}
 
@@ -133,9 +128,9 @@ func (g *GrafanaServerImpl) Run() error {
 
 			// If error is not canceled then the service crashed
 			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 {
-				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

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

@@ -6,6 +6,7 @@ import (
 	"fmt"
 
 	"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/models"
 	"github.com/grafana/grafana/pkg/tsdb"
@@ -79,6 +80,14 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
 			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() {
 			points := tsdb.TimeSeriesPoints{}
 

+ 35 - 4
pkg/registry/registry.go

@@ -2,15 +2,35 @@ package registry
 
 import (
 	"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
 }
 
@@ -27,7 +47,18 @@ type BackgroundService interface {
 	Run(ctx context.Context) error
 }
 
+type HasInitPriority interface {
+	GetInitPriority() Priority
+}
+
 func IsDisabled(srv Service) bool {
 	canBeDisabled, ok := srv.(CanBeDisabled)
 	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 (
-	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) {

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

@@ -15,10 +15,10 @@ import (
 )
 
 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
 )

+ 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"
 	"time"
 
-	"github.com/go-xorm/xorm"
 	. "github.com/smartystreets/goconvey/convey"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -110,14 +109,14 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 }
 
 func TestDeleteExpiredSnapshots(t *testing.T) {
-	x := InitTestDB(t)
+	sqlstore := InitTestDB(t)
 
 	Convey("Testing dashboard snapshots clean up", t, func() {
 		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{})
 		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{
 		Key:       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
 	if expires < 0 {
 		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)
 	}
 

+ 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)
 			So(err, ShouldBeNil)
 			So(has, ShouldBeTrue)
-			expectedMigrations := mg.MigrationsCount() - 2 //we currently skip to migrations. We should rewrite skipped migrations to write in the log as well. until then we have to keep this
+			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)
 
 			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))
 	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{
 		{Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false},
 		{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)
 		results, err := sess.SQL(sql).Query(args...)
 		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()
 		}
 	}

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

@@ -43,6 +43,7 @@ func TestQuotaCommandsAndQueries(t *testing.T) {
 			Name:   "TestOrg",
 			UserId: 1,
 		}
+
 		err := CreateOrg(&userCmd)
 		So(err, ShouldBeNil)
 		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/log"
 	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/sqlstore/migrations"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
@@ -27,151 +28,164 @@ import (
 	_ "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 (
 	x       *xorm.Engine
 	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 {
-		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
 	dialect = migrator.NewDialect(x)
-
 	migrator := migrator.NewMigrator(x)
 	migrations.AddMigrations(migrator)
 
 	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
 	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
 }
 
-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:
 		protocol := "tcp"
-		if strings.HasPrefix(DbCfg.Host, "/") {
+		if strings.HasPrefix(ss.dbCfg.Host, "/") {
 			protocol = "unix"
 		}
 
 		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 {
-				return nil, err
+				return "", err
 			}
 			mysql.RegisterTLSConfig("custom", tlsCert)
 			cnnstr += "&tls=custom"
 		}
 	case migrator.POSTGRES:
 		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 {
 			host = fields[0]
 		}
 		if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
 			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:
-		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:
-		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 {
 		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 {
 		engine.SetLogger(&xorm.DiscardLogger{})
 	} else {
@@ -183,95 +197,90 @@ func getEngine() (*xorm.Engine, error) {
 	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()
 	if len(cfgURL) != 0 {
 		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, "/")
 		if len(pathSplit) > 1 {
-			DbCfg.Name = pathSplit[1]
+			ss.dbCfg.Name = pathSplit[1]
 		}
 
 		userInfo := dbURL.User
 		if userInfo != nil {
-			DbCfg.User = userInfo.Username()
-			DbCfg.Pwd, _ = userInfo.Password()
+			ss.dbCfg.User = userInfo.Username()
+			ss.dbCfg.Pwd, _ = userInfo.Password()
 		}
 	} 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?
 	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:
-		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 {
 		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)
 	}
 
-	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 {
@@ -289,3 +298,15 @@ func IsTestDbPostgres() bool {
 
 	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 {
-	return &Cfg{}
+	return &Cfg{
+		Raw: ini.Empty(),
+	}
 }
 
 func (cfg *Cfg) Load(args *CommandLineArgs) error {

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

@@ -989,17 +989,17 @@ kbn.getUnitFormats = function() {
     {
       text: 'velocity',
       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: 'volume',
       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: 'Normal cubic metre', value: 'Nm3' },
         { text: 'cubic decimetre', value: 'dm3' },

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

@@ -312,7 +312,7 @@ class MetricsPanelCtrl extends PanelCtrl {
 
   getAdditionalMenuItems() {
     const items = [];
-    if (this.datasource.supportsExplore) {
+    if (this.datasource && this.datasource.supportsExplore) {
       items.push({
         text: '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 [];
     }
 
+    var influxdb11format = query.toLowerCase().indexOf('show tag values') >= 0;
+
     var res = {};
     _.each(influxResults.series, serie => {
       _.each(serie.values, 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 {
             addUnique(res, value[0]);
           }
@@ -38,7 +29,7 @@ export default class ResponseParser {
     });
 
     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', () => {
     var query = 'SHOW FIELD KEYS FROM "cpu"';
     describe('response from 0.10.0', () => {

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

@@ -27,6 +27,7 @@ export class PrometheusDatasource {
   withCredentials: any;
   metricsNameCache: any;
   interval: string;
+  queryTimeout: string;
   httpMethod: string;
   resultTransformer: ResultTransformer;
 
@@ -42,6 +43,7 @@ export class PrometheusDatasource {
     this.basicAuth = instanceSettings.basicAuth;
     this.withCredentials = instanceSettings.withCredentials;
     this.interval = instanceSettings.jsonData.timeInterval || '15s';
+    this.queryTimeout = instanceSettings.jsonData.queryTimeout;
     this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
     this.resultTransformer = new ResultTransformer(templateSrv);
   }
@@ -107,10 +109,18 @@ export class PrometheusDatasource {
     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) {
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
-    var range = Math.ceil(end - start);
 
     var queries = [];
     var activeTargets = [];
@@ -123,7 +133,7 @@ export class PrometheusDatasource {
       }
 
       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.
@@ -133,7 +143,7 @@ export class PrometheusDatasource {
 
     var allQueryPromise = _.map(queries, query => {
       if (!query.instant) {
-        return this.performTimeSeriesQuery(query, start, end);
+        return this.performTimeSeriesQuery(query, query.start, query.end);
       } else {
         return this.performInstantQuery(query, end);
       }
@@ -147,7 +157,8 @@ export class PrometheusDatasource {
           throw response.error;
         }
 
-        let transformerOptions = {
+        // Keeping original start/end for transformers
+        const transformerOptions = {
           format: activeTargets[index].format,
           step: queries[index].step,
           legendFormat: activeTargets[index].legendFormat,
@@ -165,9 +176,10 @@ export class PrometheusDatasource {
     });
   }
 
-  createQuery(target, options, range) {
+  createQuery(target, options, start, end) {
     var query: any = {};
     query.instant = target.instant;
+    var range = Math.ceil(end - start);
 
     var interval = kbn.interval_to_seconds(options.interval);
     // 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
     query.expr = this.templateSrv.replace(target.expr, scopedVars, this.interpolateQueryExpr);
     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;
   }
 
@@ -215,6 +233,9 @@ export class PrometheusDatasource {
       end: end,
       step: query.step,
     };
+    if (this.queryTimeout) {
+      data['timeout'] = this.queryTimeout;
+    }
     return this._request(url, data, { requestId: query.requestId });
   }
 
@@ -224,6 +245,9 @@ export class PrometheusDatasource {
       query: query.expr,
       time: time,
     };
+    if (this.queryTimeout) {
+      data['timeout'] = this.queryTimeout;
+    }
     return this._request(url, data, { requestId: query.requestId });
   }
 
@@ -270,22 +294,18 @@ export class PrometheusDatasource {
       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 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;
-    return this.performTimeSeriesQuery(query, start, end).then(function(results) {
+    return this.performTimeSeriesQuery(query, query.start, query.end).then(function(results) {
       var eventList = [];
       tagKeys = tagKeys.split(',');
 

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

@@ -2,15 +2,25 @@
 </datasource-http-settings>
 
 <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 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()">
       </input>
       <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>
     </div>
 
@@ -25,7 +25,8 @@
         placeholder="{{ctrl.panelCtrl.interval}}" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.refreshMetricData()"
       />
       <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>
     </div>
 
@@ -57,4 +58,4 @@
       <div class="gf-form-label gf-form-label--grow"></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 { 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() {
   var ctx = new helpers.ServiceTestContext();
   var instanceSettings = {
@@ -29,18 +35,16 @@ describe('PrometheusDatasource', function() {
       $httpBackend.when('GET', /\.html$/).respond('');
     })
   );
-
   describe('When querying prometheus with one target using query editor target spec', function() {
     var results;
-    var urlExpected =
-      'proxied/api/v1/query_range?query=' +
-      encodeURIComponent('test{job="testjob"}') +
-      '&start=1443438675&end=1443460275&step=60';
     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' }],
       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 = {
       status: 'success',
       data: {
@@ -48,7 +52,7 @@ describe('PrometheusDatasource', function() {
         result: [
           {
             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() {
     var results;
-    var start = 1443438675;
-    var end = 1443460275;
+    var start = 60;
+    var end = 360;
     var step = 60;
     var urlExpected =
       'proxied/api/v1/query_range?query=' +
@@ -83,7 +87,7 @@ describe('PrometheusDatasource', function() {
       '&step=' +
       step;
     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' }],
       interval: '60s',
     };
@@ -139,9 +143,9 @@ describe('PrometheusDatasource', function() {
   });
   describe('When querying prometheus with one target and instant = true', function() {
     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 = {
-      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 }],
       interval: '60s',
     };
@@ -152,7 +156,7 @@ describe('PrometheusDatasource', function() {
         result: [
           {
             metric: { __name__: 'test', job: 'testjob' },
-            value: [1443454528, '3846'],
+            value: [123, '3846'],
           },
         ],
       },
@@ -177,7 +181,7 @@ describe('PrometheusDatasource', function() {
     var urlExpected =
       'proxied/api/v1/query_range?query=' +
       encodeURIComponent('ALERTS{alertstate="firing"}') +
-      '&start=1443438675&end=1443460275&step=60s';
+      '&start=120&end=180&step=60';
     var options = {
       annotation: {
         expr: 'ALERTS{alertstate="firing"}',
@@ -186,8 +190,8 @@ describe('PrometheusDatasource', function() {
         textFormat: '{{instance}}',
       },
       range: {
-        from: moment(1443438674760),
-        to: moment(1443460274760),
+        from: time({ seconds: 63 }),
+        to: time({ seconds: 123 }),
       },
     };
     var response = {
@@ -203,7 +207,7 @@ describe('PrometheusDatasource', function() {
               instance: 'testinstance',
               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].title).to.be('InstanceDown');
       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() {
     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 = {
-      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 }],
       interval: '60s',
     };
@@ -240,7 +244,7 @@ describe('PrometheusDatasource', function() {
         result: [
           {
             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() {
       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: [
           {
             expr: 'test',
@@ -280,7 +284,7 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -288,12 +292,12 @@ describe('PrometheusDatasource', function() {
 
     it('step should never go below 1', function() {
       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' }],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -301,8 +305,8 @@ describe('PrometheusDatasource', function() {
 
     it('should be auto interval when greater than min interval', function() {
       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: [
           {
             expr: 'test',
@@ -311,7 +315,7 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -319,19 +323,21 @@ describe('PrometheusDatasource', function() {
     it('should result in querying fewer than 11000 data points', function() {
       var query = {
         // 6 hour range
-        range: { from: moment(1443438674760), to: moment(1443460274760) },
+        range: { from: time({ hours: 1 }), to: time({ hours: 7 }) },
         targets: [{ expr: 'test' }],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     it('should not apply min interval when interval * intervalFactor greater', function() {
       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: [
           {
             expr: 'test',
@@ -341,15 +347,16 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     it('should apply min interval when interval * intervalFactor smaller', function() {
       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: [
           {
             expr: 'test',
@@ -359,15 +366,15 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
     it('should apply intervalFactor to auto interval when greater', function() {
       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: [
           {
             expr: 'test',
@@ -377,7 +384,8 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       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() {
       var query = {
         // 1 week range
-        range: { from: moment(1443438674760), to: moment(1444043474760) },
+        range: { from: time({}), to: time({ hours: 7 * 24 }) },
         targets: [
           {
             expr: 'test',
@@ -394,7 +402,9 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -402,7 +412,7 @@ describe('PrometheusDatasource', function() {
     it('should be determined by the 11000 data points limit when too small', function() {
       var query = {
         // 1 week range
-        range: { from: moment(1443438674760), to: moment(1444043474760) },
+        range: { from: time({}), to: time({ hours: 7 * 24 }) },
         targets: [
           {
             expr: 'test',
@@ -411,12 +421,15 @@ describe('PrometheusDatasource', function() {
         ],
         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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
     });
   });
+
   describe('The __interval and __interval_ms template variables', function() {
     var response = {
       status: 'success',
@@ -428,8 +441,8 @@ describe('PrometheusDatasource', function() {
 
     it('should be unchanged when auto interval is greater than min interval', function() {
       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: [
           {
             expr: 'rate(test[$__interval])',
@@ -443,9 +456,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -457,8 +468,8 @@ describe('PrometheusDatasource', function() {
     });
     it('should be min interval when it is greater than auto interval', function() {
       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: [
           {
             expr: 'rate(test[$__interval])',
@@ -472,9 +483,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -486,8 +495,8 @@ describe('PrometheusDatasource', function() {
     });
     it('should account for intervalFactor', function() {
       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: [
           {
             expr: 'rate(test[$__interval])',
@@ -502,9 +511,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -516,8 +523,8 @@ describe('PrometheusDatasource', function() {
     });
     it('should be interval * intervalFactor when greater than min interval', function() {
       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: [
           {
             expr: 'rate(test[$__interval])',
@@ -532,9 +539,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -546,8 +551,8 @@ describe('PrometheusDatasource', function() {
     });
     it('should be min interval when greater than interval * intervalFactor', function() {
       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: [
           {
             expr: 'rate(test[$__interval])',
@@ -562,9 +567,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       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.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -577,7 +580,7 @@ describe('PrometheusDatasource', function() {
     it('should be determined by the 11000 data points limit, accounting for intervalFactor', function() {
       var query = {
         // 1 week range
-        range: { from: moment(1443438674760), to: moment(1444043474760) },
+        range: { from: time({}), to: time({ hours: 7 * 24 }) },
         targets: [
           {
             expr: 'rate(test[$__interval])',
@@ -590,10 +593,16 @@ describe('PrometheusDatasource', function() {
           __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
         },
       };
+      var end = 7 * 24 * 60 * 60;
+      var start = 0;
       var urlExpected =
         'proxied/api/v1/query_range?query=' +
         encodeURIComponent('rate(test[60s])') +
-        '&start=1443438675&end=1444043475&step=60';
+        '&start=' +
+        start +
+        '&end=' +
+        end +
+        '&step=60';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -604,6 +613,29 @@ describe('PrometheusDatasource', function() {
       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() {
@@ -635,12 +667,12 @@ describe('PrometheusDatasource for POST', function() {
     var urlExpected = 'proxied/api/v1/query_range';
     var dataExpected = $.param({
       query: 'test{job="testjob"}',
-      start: 1443438675,
-      end: 1443460275,
+      start: 2 * 60,
+      end: 3 * 60,
       step: 60,
     });
     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' }],
       interval: '60s',
     };
@@ -651,7 +683,7 @@ describe('PrometheusDatasource for POST', function() {
         result: [
           {
             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;
         }
 
-        if ((ranges.ctrlKey || ranges.metaKey) && dashboard.meta.canEdit) {
+        if ((ranges.ctrlKey || ranges.metaKey) && (dashboard.meta.canEdit || dashboard.meta.canMakeEditable)) {
           // Add annotation
           setTimeout(() => {
             eventManager.updateTime(ranges.xaxis);
@@ -695,7 +695,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
           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)
           let isRangeSelection = pos.x !== pos.x1;
           if (!isRangeSelection) {

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

@@ -287,6 +287,10 @@ module.directive('graphLegend', function(popoverSrv, $timeout) {
           destroyScrollbar();
           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();
       }
 

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

@@ -44,10 +44,18 @@ div.flot-text {
   padding: $panel-padding;
   height: calc(100% - 27px);
   position: relative;
+
   // Fixes scrolling on mobile devices
   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 {
   min-height: 9px;
   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'),
   };
 
+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, {
   devtool: "cheap-module-source-map",
 
   entry: entries,
 
+  output: output,
+
   resolve: {
     extensions: ['.scss', '.ts', '.tsx', '.es6', '.js', '.json', '.svg', '.woff2', '.png'],
   },
@@ -66,23 +79,20 @@ module.exports = merge(common, {
       {
         test: /\.tsx?$/,
         exclude: /node_modules/,
-        use: [
-          {
-            loader: 'babel-loader',
-            options: {
+        use: {
+          loader: 'awesome-typescript-loader',
+          options: {
+            useCache: true,
+            useBabel: HOT,
+            babelOptions: {
+              babelrc: false,
               plugins: [
                 'syntax-dynamic-import',
-                'react-hot-loader/babel',
-              ],
-            },
+                'react-hot-loader/babel'
+              ]
+            }
           },
-          {
-            loader: 'awesome-typescript-loader',
-            options: {
-              useCache: true,
-            },
-          }
-        ]
+        }
       },
       require('./sass.rule.js')({
         sourceMap: true, minimize: false, preserveUrl: HOT