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

Merge remote-tracking branch 'upstream/master' into postgres-query-builder

Sven Klemm 7 лет назад
Родитель
Сommit
9e05477558
100 измененных файлов с 1281 добавлено и 683 удалено
  1. 18 0
      .dockerignore
  2. 2 0
      .gitignore
  3. 16 1
      CHANGELOG.md
  4. 13 1
      Gopkg.lock
  5. 2 0
      Makefile
  6. 10 16
      ROADMAP.md
  7. 1 1
      docker/blocks/apache_proxy/docker-compose.yaml
  8. 13 1
      docker/blocks/mssql/build/setup.sql.template
  9. 1 1
      docker/blocks/mssql/docker-compose.yaml
  10. 1 1
      docker/blocks/nginx_proxy/docker-compose.yaml
  11. 1 1
      docs/sources/administration/provisioning.md
  12. 3 3
      docs/sources/guides/whats-new-in-v4-1.md
  13. 1 1
      docs/sources/guides/whats-new-in-v4-2.md
  14. 1 1
      docs/sources/guides/whats-new-in-v4-6.md
  15. 3 3
      docs/sources/guides/whats-new-in-v5-1.md
  16. 1 0
      docs/sources/http_api/dashboard_permissions.md
  17. 2 0
      docs/sources/http_api/org.md
  18. 2 2
      docs/sources/installation/behind_proxy.md
  19. 5 3
      docs/sources/installation/configuration.md
  20. 3 3
      docs/sources/installation/debian.md
  21. 5 5
      docs/sources/installation/rpm.md
  22. 1 1
      docs/sources/installation/windows.md
  23. 1 1
      docs/sources/plugins/developing/apps.md
  24. 1 1
      docs/sources/plugins/developing/datasources.md
  25. 19 10
      docs/sources/plugins/developing/panels.md
  26. 1 1
      docs/sources/plugins/developing/plugin.json.md
  27. 2 0
      docs/sources/reference/dashboard.md
  28. 4 4
      docs/sources/tutorials/iis.md
  29. 1 1
      docs/sources/tutorials/screencasts.md
  30. 3 0
      package.json
  31. 19 10
      pkg/api/http_server.go
  32. 3 1
      pkg/cmd/grafana-cli/commands/commands.go
  33. 8 24
      pkg/cmd/grafana-server/main.go
  34. 57 53
      pkg/cmd/grafana-server/server.go
  35. 9 0
      pkg/components/null/float.go
  36. 1 1
      pkg/login/ldap.go
  37. 14 0
      pkg/login/ldap_test.go
  38. 5 1
      pkg/login/ldap_user.go
  39. 11 0
      pkg/middleware/auth_proxy.go
  40. 7 6
      pkg/models/notifications.go
  41. 9 0
      pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go
  42. 35 4
      pkg/registry/registry.go
  43. 12 12
      pkg/services/alerting/engine.go
  44. 173 0
      pkg/services/alerting/notifiers/discord.go
  45. 52 0
      pkg/services/alerting/notifiers/discord_test.go
  46. 7 6
      pkg/services/notifications/notifications.go
  47. 13 7
      pkg/services/notifications/webhook.go
  48. 1 1
      pkg/services/provisioning/dashboards/config_reader.go
  49. 3 3
      pkg/services/provisioning/dashboards/config_reader_test.go
  50. 2 5
      pkg/services/provisioning/dashboards/dashboard.go
  51. 4 4
      pkg/services/provisioning/dashboards/file_reader_test.go
  52. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/broken-configs/commented.yaml
  53. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml
  54. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/sample.yaml
  55. 0 0
      pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml
  56. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/empty-json.json
  57. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/invalid.json
  58. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json
  59. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json
  60. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json
  61. 0 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json
  62. 23 13
      pkg/services/provisioning/provisioning.go
  63. 1 1
      pkg/services/sqlstore/alert.go
  64. 0 2
      pkg/services/sqlstore/alert_notification_test.go
  65. 5 5
      pkg/services/sqlstore/annotation.go
  66. 16 3
      pkg/services/sqlstore/annotation_test.go
  67. 30 32
      pkg/services/sqlstore/dashboard_snapshot_test.go
  68. 2 8
      pkg/services/sqlstore/migrations/annotation_mig.go
  69. 1 4
      pkg/services/sqlstore/migrations/dashboard_acl.go
  70. 2 4
      pkg/services/sqlstore/migrations/dashboard_mig.go
  71. 1 3
      pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go
  72. 3 11
      pkg/services/sqlstore/migrations/dashboard_version_mig.go
  73. 1 4
      pkg/services/sqlstore/migrations/datasource_mig.go
  74. 2 2
      pkg/services/sqlstore/migrations/migrations_test.go
  75. 1 25
      pkg/services/sqlstore/migrations/org_mig.go
  76. 3 4
      pkg/services/sqlstore/migrations/user_auth_mig.go
  77. 2 41
      pkg/services/sqlstore/migrator/column.go
  78. 103 11
      pkg/services/sqlstore/migrator/dialect.go
  79. 38 17
      pkg/services/sqlstore/migrator/migrations.go
  80. 2 2
      pkg/services/sqlstore/migrator/migrator.go
  81. 25 5
      pkg/services/sqlstore/migrator/mysql_dialect.go
  82. 20 6
      pkg/services/sqlstore/migrator/postgres_dialect.go
  83. 11 6
      pkg/services/sqlstore/migrator/sqlite_dialect.go
  84. 1 0
      pkg/services/sqlstore/migrator/types.go
  85. 1 1
      pkg/services/sqlstore/playlist.go
  86. 1 0
      pkg/services/sqlstore/quota_test.go
  87. 3 5
      pkg/services/sqlstore/search_builder.go
  88. 2 5
      pkg/services/sqlstore/search_builder_test.go
  89. 21 0
      pkg/services/sqlstore/shared.go
  90. 167 144
      pkg/services/sqlstore/sqlstore.go
  91. 0 37
      pkg/services/sqlstore/sqlutil/sqlutil.go
  92. 1 2
      pkg/services/sqlstore/team.go
  93. 8 2
      pkg/services/sqlstore/user.go
  94. 22 9
      pkg/setting/setting.go
  95. 55 43
      pkg/tracing/tracing.go
  96. 17 2
      public/app/containers/Explore/Explore.tsx
  97. 15 4
      public/app/containers/Explore/QueryRows.tsx
  98. 1 0
      public/app/core/services/analytics.ts
  99. 13 1
      public/app/core/services/keybindingSrv.ts
  100. 79 18
      public/app/core/specs/file_export.jest.ts

+ 18 - 0
.dockerignore

@@ -0,0 +1,18 @@
+.awcache
+.dockerignore
+.git
+.gitignore
+.github
+data*
+dist
+docker
+docs
+dump.rdb
+node_modules
+/local
+/tmp
+/vendor
+*.yml
+*.md
+/vendor
+/tmp

+ 2 - 0
.gitignore

@@ -44,7 +44,9 @@ docker-compose.yaml
 /conf/provisioning/**/custom.yaml
 profile.cov
 /grafana
+/local
 .notouch
+/Makefile.local
 /pkg/cmd/grafana-cli/grafana-cli
 /pkg/cmd/grafana-server/grafana-server
 /pkg/cmd/grafana-server/debug

+ 16 - 1
CHANGELOG.md

@@ -7,12 +7,27 @@
 * **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda)
 * **Variables**: Fix variable interpolation when using multiple formatting types [#11800](https://github.com/grafana/grafana/issues/11800), thx [@svenklemm](https://github.com/svenklemm)
 * **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616)
+* **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel),
+* **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013)
+* **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel)
+* **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.1 (unreleased)
+# 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)
+* **Google Analytics**: Enable Google Analytics anonymizeIP setting for GDPR [#11656](https://github.com/grafana/grafana/pull/11656)
+
+# 5.1.1 (2018-05-07)
 
 * **LDAP**: LDAP login with MariaDB/MySQL database and dn>100 chars not possible [#11754](https://github.com/grafana/grafana/issues/11754)
 * **Build**: AppVeyor Windows build missing version and commit info [#11758](https://github.com/grafana/grafana/issues/11758)
 * **Scroll**: Scroll can't start in graphs on Chrome mobile [#11710](https://github.com/grafana/grafana/issues/11710)
+* **Units**: Revert renaming of unit key ppm [#11743](https://github.com/grafana/grafana/issues/11743)
 
 # 5.1.0 (2018-04-26)
 

+ 13 - 1
Gopkg.lock

@@ -111,6 +111,18 @@
   ]
   revision = "270bc3860bb94dd3a3ffd047377d746c5e276726"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/facebookgo/inject"
+  packages = ["."]
+  revision = "cc1aa653e50f6a9893bcaef89e673e5b24e1e97b"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/facebookgo/structtag"
+  packages = ["."]
+  revision = "217e25fb96916cc60332e399c9aa63f5c422ceed"
+
 [[projects]]
   name = "github.com/fatih/color"
   packages = ["."]
@@ -649,6 +661,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "2bd5b309496d57e2189a1cc28f5c1c41398c19729ba0cf53c8cbb17ea3f706b5"
+  inputs-digest = "bd54a1a836599d90b36d4ac1af56d716ef9ca5be4865e217bddd49e3d32a1997"
   solver-name = "gps-cdcl"
   solver-version = 1

+ 2 - 0
Makefile

@@ -1,3 +1,5 @@
+-include local/Makefile
+
 all: deps build
 
 deps-go:

+ 10 - 16
ROADMAP.md

@@ -1,26 +1,20 @@
-# Roadmap (2018-02-22)
+# Roadmap (2018-05-06)
 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 But it will give you an idea of our current vision and plan. 
-
-### Short term (1-2 months)
-
-- v5.1
-  - Build speed improvements & integration test execution
-  - Kubernetes friendly docker container
-  - Enterprise LDAP
-  - Provisioning workflow
-  - MSSQL datasource
   
-### Mid term (2-4 months)
+### Short term (1-2 months)
 
-- v5.2
-  - Azure monitor backend rewrite
   - Elasticsearch alerting
-  - First login registration view
-  - Backend plugins? (alert notifiers, auth)
   - Crossplatform builds
-  - IFQL Initial support
+  - Backend service refactorings
+  - Explore UI 
+  - First login registration view
+  
+### Mid term (2-4 months)
+  - Multi-Stat panel
+  - React Panels 
+  - Templating Query Editor UI Plugin hook
   
 ### Long term (4 - 8 months)
 

+ 1 - 1
docker/blocks/apache_proxy/docker-compose.yaml

@@ -2,7 +2,7 @@
 # http://localhost:3000 (Grafana running locally)
 #
 # Please note that you'll need to change the root_url in the Grafana configuration:
-# root_url = %(protocol)s://%(domain)s:/grafana/
+# root_url = %(protocol)s://%(domain)s:10081/grafana/
 
   apacheproxy:
     build: blocks/apache_proxy

+ 13 - 1
docker/blocks/mssql/build/setup.sql.template

@@ -1,7 +1,19 @@
 CREATE LOGIN %%USER%% WITH PASSWORD = '%%PWD%%'
 GO
 
-CREATE DATABASE %%DB%%;
+CREATE DATABASE %%DB%%
+ON
+( NAME = %%DB%%,
+    FILENAME = '/var/opt/mssql/data/%%DB%%.mdf',
+    SIZE = 500MB,
+    MAXSIZE = 1000MB,
+    FILEGROWTH = 100MB )
+LOG ON
+( NAME = %%DB%%_log,
+    FILENAME = '/var/opt/mssql/data/%%DB%%_log.ldf',
+    SIZE = 500MB,
+    MAXSIZE = 1000MB,
+    FILEGROWTH = 100MB );
 GO
 
 USE %%DB%%;

+ 1 - 1
docker/blocks/mssql/docker-compose.yaml

@@ -4,7 +4,7 @@
     environment:
       ACCEPT_EULA: Y
       MSSQL_SA_PASSWORD: Password!
-      MSSQL_PID: Express
+      MSSQL_PID: Developer
       MSSQL_DATABASE: grafana
       MSSQL_USER: grafana
       MSSQL_PASSWORD: Password!

+ 1 - 1
docker/blocks/nginx_proxy/docker-compose.yaml

@@ -2,7 +2,7 @@
 # http://localhost:3000 (Grafana running locally)
 #
 # Please note that you'll need to change the root_url in the Grafana configuration:
-# root_url = %(protocol)s://%(domain)s:/grafana/
+# root_url = %(protocol)s://%(domain)s:10080/grafana/
 
   nginxproxy:
     build: blocks/nginx_proxy

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

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

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

@@ -100,8 +100,8 @@ In the table below you can see some examples and you can find all different opti
 Filter Option | Example | Raw | Interpolated | Description
 ------------ | ------------- | ------------- | -------------  | -------------
 `glob` | ${servers:glob} |  `'test1', 'test2'` | `{test1,test2}` | Formats multi-value variable into a glob
-`regex` | ${servers:regex} | `'test.', 'test2'` |  `(test\\.|test2)` | Formats multi-value variable into a regex string
-`pipe` | ${servers:pipe} | `'test.', 'test2'` |  `test.|test2` | Formats multi-value variable into a pipe-separated string
+`regex` | ${servers:regex} | `'test.', 'test2'` |  <code>(test\.&#124;test2)</code> | Formats multi-value variable into a regex string
+`pipe` | ${servers:pipe} | `'test.', 'test2'` |  <code>test.&#124;test2</code> | Formats multi-value variable into a pipe-separated string
 `csv`| ${servers:csv} |  `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
 
 ## Improved workflow for provisioned dashboards
@@ -122,4 +122,4 @@ More information in the [Provisioning documentation](/features/datasources/prome
 ## Changelog
 
 Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
-of new features, changes, and bug fixes.
+of new features, changes, and bug fixes.

+ 1 - 0
docs/sources/http_api/dashboard_permissions.md

@@ -106,6 +106,7 @@ Accept: application/json
 Content-Type: application/json
 Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 
+{
   "items": [
     {
       "role": "Viewer",

+ 2 - 0
docs/sources/http_api/org.md

@@ -380,6 +380,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
   "role":"Viewer"
 }
 ```
+Note: The api will only work when you pass the admin name and password
+to the request http url, like http://admin:admin@localhost:3000/api/orgs/1/users
 
 **Example Response**:
 

+ 2 - 2
docs/sources/installation/behind_proxy.md

@@ -53,7 +53,7 @@ server {
 ```bash
 [server]
 domain = foo.bar
-root_url = %(protocol)s://%(domain)s:/grafana
+root_url = %(protocol)s://%(domain)s/grafana/
 ```
 
 #### Nginx configuration with sub path
@@ -98,7 +98,7 @@ Given:
     ```bash
     [server]
     domain = localhost:8080
-    root_url = %(protocol)s://%(domain)s:/grafana
+    root_url = %(protocol)s://%(domain)s/grafana/
     ```
 
 Create an Inbound Rule for the parent website (localhost:8080 in this example) in IIS Manager with the following settings:

+ 5 - 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]
@@ -659,6 +657,10 @@ Set to `true` to enable auto sign up of users who do not exist in Grafana DB. De
 
 Limit where auth proxy requests come from by configuring a list of IP addresses. This can be used to prevent users spoofing the X-WEBAUTH-USER header.
 
+### headers
+
+Used to define additional headers for `Name`, `Email` and/or `Login`, for example if the user's name is sent in the X-WEBAUTH-NAME header and their email address in the X-WEBAUTH-EMAIL header, set `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL`.
+
 <hr>
 
 ## [session]
@@ -713,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.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0_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.0_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.0_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.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-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.0-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.0-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.0-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.0-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.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0.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/apps.md

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 name = "Developing App Plugins"
 parent = "developing"
-weight = 6
+weight = 4
 +++
 
 # Grafana Apps

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

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 name = "Developing Datasource Plugins"
 parent = "developing"
-weight = 6
+weight = 5
 +++
 
 # Datasources

+ 19 - 10
docs/sources/plugins/developing/panels.md

@@ -1,16 +1,11 @@
----
-page_title: Plugin panel
-page_description: Panel plugins for Grafana
-page_keywords: grafana, plugins, documentation
----
-
-
 +++
-title = "Installing Plugins"
+title = "Developing Panel Plugins"
+keywords = ["grafana", "plugins", "panel", "documentation"]
 type = "docs"
 [menu.docs]
+name = "Developing Panel Plugins"
 parent = "developing"
-weight = 1
+weight = 4
 +++
 
 
@@ -20,7 +15,21 @@ Panels are the main building blocks of dashboards.
 
 ## Panel development
 
-Examples
+
+### Scrolling
+The grafana dashboard framework controls the panel height.  To enable a scrollbar within the panel the PanelCtrl needs to set the scrollable static variable:
+
+```javascript
+export class MyPanelCtrl extends PanelCtrl {
+  static scrollable = true;
+  ...
+```
+
+In this case, make sure the template has a single `<div>...</div>` root.  The plugin loader will modify that element adding a scrollbar.
+
+
+
+### Examples
 
 - [clock-panel](https://github.com/grafana/clock-panel)
 - [singlestat-panel](https://github.com/grafana/grafana/blob/master/public/app/plugins/panel/singlestat/module.ts)

+ 1 - 1
docs/sources/plugins/developing/plugin.json.md

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 name = "plugin.json Schema"
 parent = "developing"
-weight = 6
+weight = 8
 +++
 
 # Plugin.json

+ 2 - 0
docs/sources/reference/dashboard.md

@@ -50,6 +50,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
   "annotations": {
     "list": []
   },
+  "refresh": "5s",
   "schemaVersion": 16,
   "version": 0,
   "links": []
@@ -71,6 +72,7 @@ Each field in the dashboard JSON is explained below with its usage:
 | **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details |
 | **templating** | templating metadata, see [templating section](#templating) for details |
 | **annotations** | annotations metadata, see [annotations section](#annotations) for details |
+| **refresh** | auto-refresh interval
 | **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to said schema |
 | **version** | version of the dashboard (integer), incremented each time the dashboard is updated |
 | **panels** | panels array, see below for detail. |

+ 4 - 4
docs/sources/tutorials/iis.md

@@ -16,7 +16,7 @@ Example:
 - Parent site: http://localhost:8080
 - Grafana: http://localhost:3000
 
-Grafana as a subpath: http://localhost:8080/grafana 
+Grafana as a subpath: http://localhost:8080/grafana
 
 ## Setup
 
@@ -33,7 +33,7 @@ Given that the subpath should be `grafana` and the parent site is `localhost:808
  ```bash
 [server]
 domain = localhost:8080
-root_url = %(protocol)s://%(domain)s:/grafana
+root_url = %(protocol)s://%(domain)s/grafana/
 ```
 
 Restart the Grafana server after changing the config file.
@@ -74,11 +74,11 @@ When navigating to the grafana url (`http://localhost:8080/grafana` in the examp
 
 1. The `root_url` setting in the Grafana config file does not match the parent url with subpath. This could happen if the root_url is commented out by mistake (`;` is used for commenting out a line in .ini files):
 
-    `; root_url = %(protocol)s://%(domain)s:/grafana`
+    `; root_url = %(protocol)s://%(domain)s/grafana/`
 
 2. or if the subpath in the `root_url` setting does not match the subpath used in the pattern in the Inbound Rule in IIS:
 
-    `root_url = %(protocol)s://%(domain)s:/grafana`
+    `root_url = %(protocol)s://%(domain)s/grafana/`
 
     pattern in Inbound Rule: `wrongsubpath(/)?(.*)`
 

+ 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 - 0
package.json

@@ -179,5 +179,8 @@
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "tinycolor2": "^1.4.1"
+  },
+  "resolutions": {
+    "caniuse-db": "1.0.30000772"
   }
 }

+ 19 - 10
pkg/api/http_server.go

@@ -26,9 +26,14 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/setting"
 )
 
+func init() {
+	registry.RegisterService(&HTTPServer{})
+}
+
 type HTTPServer struct {
 	log           log.Logger
 	macaron       *macaron.Macaron
@@ -41,12 +46,14 @@ type HTTPServer struct {
 	Bus           bus.Bus       `inject:""`
 }
 
-func (hs *HTTPServer) Init() {
+func (hs *HTTPServer) Init() error {
 	hs.log = log.New("http.server")
 	hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
+
+	return nil
 }
 
-func (hs *HTTPServer) Start(ctx context.Context) error {
+func (hs *HTTPServer) Run(ctx context.Context) error {
 	var err error
 
 	hs.context = ctx
@@ -57,17 +64,18 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
 	hs.streamManager.Run(ctx)
 
 	listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
-	hs.log.Info("Initializing HTTP Server", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
+	hs.log.Info("HTTP Server Listen", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
 
 	hs.httpSrv = &http.Server{Addr: listenAddr, Handler: hs.macaron}
 
 	// handle http shutdown on server context done
 	go func() {
 		<-ctx.Done()
+		// Hacky fix for race condition between ListenAndServe and Shutdown
+		time.Sleep(time.Millisecond * 100)
 		if err := hs.httpSrv.Shutdown(context.Background()); err != nil {
 			hs.log.Error("Failed to shutdown server", "error", err)
 		}
-		hs.log.Info("Stopped HTTP Server")
 	}()
 
 	switch setting.Protocol {
@@ -106,12 +114,6 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
 	return err
 }
 
-func (hs *HTTPServer) Shutdown(ctx context.Context) error {
-	err := hs.httpSrv.Shutdown(ctx)
-	hs.log.Info("Stopped HTTP server")
-	return err
-}
-
 func (hs *HTTPServer) listenAndServeTLS(certfile, keyfile string) error {
 	if certfile == "" {
 		return fmt.Errorf("cert_file cannot be empty when using HTTPS")
@@ -172,6 +174,7 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
 		hs.mapStatic(m, route.Directory, "", pluginRoute)
 	}
 
+	hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
 	hs.mapStatic(m, setting.StaticRootPath, "", "public")
 	hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
 
@@ -239,6 +242,12 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string,
 		c.Resp.Header().Set("Cache-Control", "public, max-age=3600")
 	}
 
+	if prefix == "public/build" {
+		headers = func(c *macaron.Context) {
+			c.Resp.Header().Set("Cache-Control", "public, max-age=31536000")
+		}
+	}
+
 	if setting.Env == setting.DEV {
 		headers = func(c *macaron.Context) {
 			c.Resp.Header().Set("Cache-Control", "max-age=0, must-revalidate, no-cache")

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

+ 8 - 24
pkg/cmd/grafana-server/main.go

@@ -39,7 +39,6 @@ var enterprise string
 var configFile = flag.String("config", "", "path to config file")
 var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
 var pidFile = flag.String("pidfile", "", "path to pid file")
-var exitChan = make(chan int)
 
 func main() {
 	v := flag.Bool("v", false, "prints current version and exits")
@@ -81,29 +80,20 @@ func main() {
 	setting.Enterprise, _ = strconv.ParseBool(enterprise)
 
 	metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
-	shutdownCompleted := make(chan int)
-	server := NewGrafanaServer()
 
-	go listenToSystemSignals(server, shutdownCompleted)
+	server := NewGrafanaServer()
 
-	go func() {
-		code := 0
-		if err := server.Start(); err != nil {
-			log.Error2("Startup failed", "error", err)
-			code = 1
-		}
+	go listenToSystemSignals(server)
 
-		exitChan <- code
-	}()
+	err := server.Run()
 
-	code := <-shutdownCompleted
-	log.Info2("Grafana shutdown completed.", "code", code)
+	trace.Stop()
 	log.Close()
-	os.Exit(code)
+
+	server.Exit(err)
 }
 
-func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int) {
-	var code int
+func listenToSystemSignals(server *GrafanaServerImpl) {
 	signalChan := make(chan os.Signal, 1)
 	ignoreChan := make(chan os.Signal, 1)
 
@@ -112,12 +102,6 @@ func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int
 
 	select {
 	case sig := <-signalChan:
-		trace.Stop() // Stops trace if profiling has been enabled
-		server.Shutdown(0, fmt.Sprintf("system signal: %s", sig))
-		shutdownCompleted <- 0
-	case code = <-exitChan:
-		trace.Stop() // Stops trace if profiling has been enabled
-		server.Shutdown(code, "startup error")
-		shutdownCompleted <- code
+		server.Shutdown(fmt.Sprintf("System signal: %s", sig))
 	}
 }

+ 57 - 53
pkg/cmd/grafana-server/server.go

@@ -8,7 +8,6 @@ import (
 	"net"
 	"os"
 	"path/filepath"
-	"reflect"
 	"strconv"
 	"time"
 
@@ -16,19 +15,15 @@ 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"
-	"github.com/grafana/grafana/pkg/services/provisioning"
 
 	"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"
-	"github.com/grafana/grafana/pkg/tracing"
 
 	// self registering services
 	_ "github.com/grafana/grafana/pkg/extensions"
@@ -37,7 +32,10 @@ import (
 	_ "github.com/grafana/grafana/pkg/services/alerting"
 	_ "github.com/grafana/grafana/pkg/services/cleanup"
 	_ "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"
 )
 
 func NewGrafanaServer() *GrafanaServerImpl {
@@ -54,50 +52,36 @@ func NewGrafanaServer() *GrafanaServerImpl {
 }
 
 type GrafanaServerImpl struct {
-	context       context.Context
-	shutdownFn    context.CancelFunc
-	childRoutines *errgroup.Group
-	log           log.Logger
-	cfg           *setting.Cfg
+	context            context.Context
+	shutdownFn         context.CancelFunc
+	childRoutines      *errgroup.Group
+	log                log.Logger
+	cfg                *setting.Cfg
+	shutdownReason     string
+	shutdownInProgress bool
 
 	RouteRegister api.RouteRegister `inject:""`
 	HttpServer    *api.HTTPServer   `inject:""`
 }
 
-func (g *GrafanaServerImpl) Start() error {
+func (g *GrafanaServerImpl) Run() error {
 	g.loadConfiguration()
 	g.writePIDFile()
 
-	// initSql
-	sqlstore.NewEngine() // TODO: this should return an error
-	sqlstore.EnsureAdminUser()
-
 	login.Init()
 	social.NewOAuthService()
 
-	if err := provisioning.Init(g.context, setting.HomePath, g.cfg.Raw); err != nil {
-		return fmt.Errorf("Failed to provision Grafana from config. error: %v", err)
-	}
-
-	tracingCloser, err := tracing.Init(g.cfg.Raw)
-	if err != nil {
-		return fmt.Errorf("Tracing settings is not valid. error: %v", err)
-	}
-	defer tracingCloser.Close()
-
 	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)})
-	serviceGraph.Provide(&inject.Object{Value: api.HTTPServer{}})
 
 	// self registered services
 	services := registry.GetServices()
 
 	// 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})
@@ -109,37 +93,56 @@ func (g *GrafanaServerImpl) Start() 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 {
-			return fmt.Errorf("Service init failed %v", err)
+		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
 		}
 
 		g.childRoutines.Go(func() error {
+			// Skip starting new service when shutting down
+			// Can happen when service stop/return during startup
+			if g.shutdownInProgress {
+				return nil
+			}
+
 			err := service.Run(g.context)
-			g.log.Info("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
+
+			// If error is not canceled then the service crashed
+			if err != context.Canceled && err != nil {
+				g.log.Error("Stopped "+descriptor.Name, "reason", err)
+			} else {
+				g.log.Info("Stopped "+descriptor.Name, "reason", err)
+			}
+
+			// Mark that we are in shutdown mode
+			// So more services are not started
+			g.shutdownInProgress = true
 			return err
 		})
 	}
 
 	sendSystemdNotification("READY=1")
-	return g.startHttpServer()
+
+	return g.childRoutines.Wait()
 }
 
 func (g *GrafanaServerImpl) loadConfiguration() {
@@ -158,28 +161,29 @@ func (g *GrafanaServerImpl) loadConfiguration() {
 	g.cfg.LogConfigSources()
 }
 
-func (g *GrafanaServerImpl) startHttpServer() error {
-	g.HttpServer.Init()
-
-	err := g.HttpServer.Start(g.context)
-
-	if err != nil {
-		return fmt.Errorf("Fail to start server. error: %v", err)
-	}
-
-	return nil
-}
-
-func (g *GrafanaServerImpl) Shutdown(code int, reason string) {
-	g.log.Info("Shutdown started", "code", code, "reason", reason)
+func (g *GrafanaServerImpl) Shutdown(reason string) {
+	g.log.Info("Shutdown started", "reason", reason)
+	g.shutdownReason = reason
+	g.shutdownInProgress = true
 
 	// call cancel func on root context
 	g.shutdownFn()
 
 	// wait for child routines
-	if err := g.childRoutines.Wait(); err != nil && err != context.Canceled {
-		g.log.Error("Server shutdown completed", "error", err)
+	g.childRoutines.Wait()
+}
+
+func (g *GrafanaServerImpl) Exit(reason error) {
+	// default exit code is 1
+	code := 1
+
+	if reason == context.Canceled && g.shutdownReason != "" {
+		reason = fmt.Errorf(g.shutdownReason)
+		code = 0
 	}
+
+	g.log.Error("Server shutdown", "reason", reason)
+	os.Exit(code)
 }
 
 func (g *GrafanaServerImpl) writePIDFile() {

+ 9 - 0
pkg/components/null/float.go

@@ -106,6 +106,15 @@ func (f Float) String() string {
 	return fmt.Sprintf("%1.3f", f.Float64)
 }
 
+// FullString returns float as string in full precision
+func (f Float) FullString() string {
+	if !f.Valid {
+		return "null"
+	}
+
+	return fmt.Sprintf("%f", f.Float64)
+}
+
 // SetValid changes this Float's value and also sets it to be non-null.
 func (f *Float) SetValid(n float64) {
 	f.Float64 = n

+ 1 - 1
pkg/login/ldap.go

@@ -349,7 +349,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
 
 func getLdapAttrN(name string, result *ldap.SearchResult, n int) string {
 	if name == "DN" {
-		return result.Entries[0].DN
+		return result.Entries[n].DN
 	}
 	for _, attr := range result.Entries[n].Attributes {
 		if attr.Name == name {

+ 14 - 0
pkg/login/ldap_test.go

@@ -53,6 +53,20 @@ func TestLdapAuther(t *testing.T) {
 			So(result, ShouldEqual, user1)
 		})
 
+		ldapAutherScenario("Given group match with different case", func(sc *scenarioContext) {
+			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
+				LdapGroups: []*LdapGroupToOrgRole{
+					{GroupDN: "cn=users", OrgRole: "Admin"},
+				},
+			})
+
+			sc.userQueryReturns(user1)
+
+			result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{MemberOf: []string{"CN=users"}})
+			So(err, ShouldBeNil)
+			So(result, ShouldEqual, user1)
+		})
+
 		ldapAutherScenario("Given no existing grafana user", func(sc *scenarioContext) {
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 				LdapGroups: []*LdapGroupToOrgRole{

+ 5 - 1
pkg/login/ldap_user.go

@@ -1,5 +1,9 @@
 package login
 
+import (
+	"strings"
+)
+
 type LdapUserInfo struct {
 	DN        string
 	FirstName string
@@ -15,7 +19,7 @@ func (u *LdapUserInfo) isMemberOf(group string) bool {
 	}
 
 	for _, member := range u.MemberOf {
-		if member == group {
+		if strings.EqualFold(member, group) {
 			return true
 		}
 	}

+ 11 - 0
pkg/middleware/auth_proxy.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"net"
 	"net/mail"
+	"reflect"
 	"strings"
 	"time"
 
@@ -111,6 +112,16 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 			return true
 		}
 
+		for _, field := range []string{"Name", "Email", "Login"} {
+			if setting.AuthProxyHeaders[field] == "" {
+				continue
+			}
+
+			if val := ctx.Req.Header.Get(setting.AuthProxyHeaders[field]); val != "" {
+				reflect.ValueOf(extUser).Elem().FieldByName(field).SetString(val)
+			}
+		}
+
 		// add/update user in grafana
 		cmd := &m.UpsertUserCommand{
 			ReqContext:    ctx,

+ 7 - 6
pkg/models/notifications.go

@@ -19,12 +19,13 @@ type SendEmailCommandSync struct {
 }
 
 type SendWebhookSync struct {
-	Url        string
-	User       string
-	Password   string
-	Body       string
-	HttpMethod string
-	HttpHeader map[string]string
+	Url         string
+	User        string
+	Password    string
+	Body        string
+	HttpMethod  string
+	HttpHeader  map[string]string
+	ContentType string
 }
 
 type SendResetPasswordEmailCommand struct {

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

+ 12 - 12
pkg/services/alerting/engine.go

@@ -16,7 +16,7 @@ import (
 	"golang.org/x/sync/errgroup"
 )
 
-type Engine struct {
+type AlertingService struct {
 	execQueue chan *Job
 	//clock         clock.Clock
 	ticker        *Ticker
@@ -28,20 +28,20 @@ type Engine struct {
 }
 
 func init() {
-	registry.RegisterService(&Engine{})
+	registry.RegisterService(&AlertingService{})
 }
 
-func NewEngine() *Engine {
-	e := &Engine{}
+func NewEngine() *AlertingService {
+	e := &AlertingService{}
 	e.Init()
 	return e
 }
 
-func (e *Engine) IsDisabled() bool {
+func (e *AlertingService) IsDisabled() bool {
 	return !setting.AlertingEnabled || !setting.ExecuteAlerts
 }
 
-func (e *Engine) Init() error {
+func (e *AlertingService) Init() error {
 	e.ticker = NewTicker(time.Now(), time.Second*0, clock.New())
 	e.execQueue = make(chan *Job, 1000)
 	e.scheduler = NewScheduler()
@@ -52,7 +52,7 @@ func (e *Engine) Init() error {
 	return nil
 }
 
-func (e *Engine) Run(ctx context.Context) error {
+func (e *AlertingService) Run(ctx context.Context) error {
 	alertGroup, ctx := errgroup.WithContext(ctx)
 	alertGroup.Go(func() error { return e.alertingTicker(ctx) })
 	alertGroup.Go(func() error { return e.runJobDispatcher(ctx) })
@@ -61,7 +61,7 @@ func (e *Engine) Run(ctx context.Context) error {
 	return err
 }
 
-func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
+func (e *AlertingService) alertingTicker(grafanaCtx context.Context) error {
 	defer func() {
 		if err := recover(); err != nil {
 			e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1))
@@ -86,7 +86,7 @@ func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
 	}
 }
 
-func (e *Engine) runJobDispatcher(grafanaCtx context.Context) error {
+func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
 	dispatcherGroup, alertCtx := errgroup.WithContext(grafanaCtx)
 
 	for {
@@ -106,7 +106,7 @@ var (
 	alertMaxAttempts = 3
 )
 
-func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
+func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
 	defer func() {
 		if err := recover(); err != nil {
 			e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
@@ -141,7 +141,7 @@ func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error
 	}
 }
 
-func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
+func (e *AlertingService) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
 	job.Running = false
 	close(cancelChan)
 	for cancelFn := range cancelChan {
@@ -150,7 +150,7 @@ func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job)
 	return err
 }
 
-func (e *Engine) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
+func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
 	defer func() {
 		if err := recover(); err != nil {
 			e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))

+ 173 - 0
pkg/services/alerting/notifiers/discord.go

@@ -0,0 +1,173 @@
+package notifiers
+
+import (
+	"bytes"
+	"io"
+	"mime/multipart"
+	"os"
+	"strconv"
+	"strings"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+func init() {
+	alerting.RegisterNotifier(&alerting.NotifierPlugin{
+		Type:        "discord",
+		Name:        "Discord",
+		Description: "Sends notifications to Discord",
+		Factory:     NewDiscordNotifier,
+		OptionsTemplate: `
+      <h3 class="page-heading">Discord settings</h3>
+      <div class="gf-form">
+        <span class="gf-form-label width-14">Webhook URL</span>
+        <input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.url" placeholder="Discord webhook URL"></input>
+      </div>
+    `,
+	})
+}
+
+func NewDiscordNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
+	url := model.Settings.Get("url").MustString()
+	if url == "" {
+		return nil, alerting.ValidationError{Reason: "Could not find webhook url property in settings"}
+	}
+
+	return &DiscordNotifier{
+		NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
+		WebhookURL:   url,
+		log:          log.New("alerting.notifier.discord"),
+	}, nil
+}
+
+type DiscordNotifier struct {
+	NotifierBase
+	WebhookURL string
+	log        log.Logger
+}
+
+func (this *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
+	this.log.Info("Sending alert notification to", "webhook_url", this.WebhookURL)
+
+	ruleUrl, err := evalContext.GetRuleUrl()
+	if err != nil {
+		this.log.Error("Failed get rule link", "error", err)
+		return err
+	}
+
+	bodyJSON := simplejson.New()
+	bodyJSON.Set("username", "Grafana")
+
+	fields := make([]map[string]interface{}, 0)
+
+	for _, evt := range evalContext.EvalMatches {
+
+		fields = append(fields, map[string]interface{}{
+			"name":   evt.Metric,
+			"value":  evt.Value.FullString(),
+			"inline": true,
+		})
+	}
+
+	footer := map[string]interface{}{
+		"text":     "Grafana v" + setting.BuildVersion,
+		"icon_url": "https://grafana.com/assets/img/fav32.png",
+	}
+
+	color, _ := strconv.ParseInt(strings.TrimLeft(evalContext.GetStateModel().Color, "#"), 16, 0)
+
+	embed := simplejson.New()
+	embed.Set("title", evalContext.GetNotificationTitle())
+	//Discord takes integer for color
+	embed.Set("color", color)
+	embed.Set("url", ruleUrl)
+	embed.Set("description", evalContext.Rule.Message)
+	embed.Set("type", "rich")
+	embed.Set("fields", fields)
+	embed.Set("footer", footer)
+
+	var image map[string]interface{}
+	var embeddedImage = false
+
+	if evalContext.ImagePublicUrl != "" {
+		image = map[string]interface{}{
+			"url": evalContext.ImagePublicUrl,
+		}
+		embed.Set("image", image)
+	} else {
+		image = map[string]interface{}{
+			"url": "attachment://graph.png",
+		}
+		embed.Set("image", image)
+		embeddedImage = true
+	}
+
+	bodyJSON.Set("embeds", []interface{}{embed})
+
+	json, _ := bodyJSON.MarshalJSON()
+
+	content_type := "application/json"
+
+	var body []byte
+
+	if embeddedImage {
+
+		var b bytes.Buffer
+
+		w := multipart.NewWriter(&b)
+
+		f, err := os.Open(evalContext.ImageOnDiskPath)
+
+		if err != nil {
+			this.log.Error("Can't open graph file", err)
+			return err
+		}
+
+		defer f.Close()
+
+		fw, err := w.CreateFormField("payload_json")
+		if err != nil {
+			return err
+		}
+
+		if _, err = fw.Write([]byte(string(json))); err != nil {
+			return err
+		}
+
+		fw, err = w.CreateFormFile("file", "graph.png")
+		if err != nil {
+			return err
+		}
+
+		if _, err = io.Copy(fw, f); err != nil {
+			return err
+		}
+
+		w.Close()
+
+		body = b.Bytes()
+		content_type = w.FormDataContentType()
+
+	} else {
+		body = json
+	}
+
+	cmd := &m.SendWebhookSync{
+		Url:         this.WebhookURL,
+		Body:        string(body),
+		HttpMethod:  "POST",
+		ContentType: content_type,
+	}
+
+	if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
+		this.log.Error("Failed to send notification to Discord", "error", err)
+		return err
+	}
+
+	return nil
+}

+ 52 - 0
pkg/services/alerting/notifiers/discord_test.go

@@ -0,0 +1,52 @@
+package notifiers
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDiscordNotifier(t *testing.T) {
+	Convey("Telegram notifier tests", t, func() {
+
+		Convey("Parsing alert notification from settings", func() {
+			Convey("empty settings should return error", func() {
+				json := `{ }`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "discord_testing",
+					Type:     "discord",
+					Settings: settingsJSON,
+				}
+
+				_, err := NewDiscordNotifier(model)
+				So(err, ShouldNotBeNil)
+			})
+
+			Convey("settings should trigger incident", func() {
+				json := `
+				{
+          "url": "https://web.hook/"
+				}`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "discord_testing",
+					Type:     "discord",
+					Settings: settingsJSON,
+				}
+
+				not, err := NewDiscordNotifier(model)
+				discordNotifier := not.(*DiscordNotifier)
+
+				So(err, ShouldBeNil)
+				So(discordNotifier.Name, ShouldEqual, "discord_testing")
+				So(discordNotifier.Type, ShouldEqual, "discord")
+				So(discordNotifier.WebhookURL, ShouldEqual, "https://web.hook/")
+			})
+		})
+	})
+}

+ 7 - 6
pkg/services/notifications/notifications.go

@@ -104,12 +104,13 @@ func (ns *NotificationService) Run(ctx context.Context) error {
 
 func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error {
 	return ns.sendWebRequestSync(ctx, &Webhook{
-		Url:        cmd.Url,
-		User:       cmd.User,
-		Password:   cmd.Password,
-		Body:       cmd.Body,
-		HttpMethod: cmd.HttpMethod,
-		HttpHeader: cmd.HttpHeader,
+		Url:         cmd.Url,
+		User:        cmd.User,
+		Password:    cmd.Password,
+		Body:        cmd.Body,
+		HttpMethod:  cmd.HttpMethod,
+		HttpHeader:  cmd.HttpHeader,
+		ContentType: cmd.ContentType,
 	})
 }
 

+ 13 - 7
pkg/services/notifications/webhook.go

@@ -15,12 +15,13 @@ import (
 )
 
 type Webhook struct {
-	Url        string
-	User       string
-	Password   string
-	Body       string
-	HttpMethod string
-	HttpHeader map[string]string
+	Url         string
+	User        string
+	Password    string
+	Body        string
+	HttpMethod  string
+	HttpHeader  map[string]string
+	ContentType string
 }
 
 var netTransport = &http.Transport{
@@ -48,8 +49,13 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
 		return err
 	}
 
-	request.Header.Add("Content-Type", "application/json")
+	if webhook.ContentType == "" {
+		webhook.ContentType = "application/json"
+	}
+
+	request.Header.Add("Content-Type", webhook.ContentType)
 	request.Header.Add("User-Agent", "Grafana")
+
 	if webhook.User != "" && webhook.Password != "" {
 		request.Header.Add("Authorization", util.GetBasicAuthHeader(webhook.User, webhook.Password))
 	}

+ 1 - 1
pkg/services/provisioning/dashboards/config_reader.go

@@ -69,7 +69,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
 
 		parsedDashboards, err := cr.parseConfigs(file)
 		if err != nil {
-
+			return nil, err
 		}
 
 		if len(parsedDashboards) > 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) {

+ 2 - 5
pkg/services/provisioning/dashboards/dashboard.go

@@ -10,19 +10,16 @@ import (
 type DashboardProvisioner struct {
 	cfgReader *configReader
 	log       log.Logger
-	ctx       context.Context
 }
 
-func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) {
+func NewDashboardProvisioner(configDirectory string) *DashboardProvisioner {
 	log := log.New("provisioning.dashboard")
 	d := &DashboardProvisioner{
 		cfgReader: &configReader{path: configDirectory, log: log},
 		log:       log,
-		ctx:       ctx,
 	}
 
-	err := d.Provision(ctx)
-	return d, err
+	return d
 }
 
 func (provider *DashboardProvisioner) Provision(ctx context.Context) error {

+ 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


+ 23 - 13
pkg/services/provisioning/provisioning.go

@@ -2,30 +2,40 @@ package provisioning
 
 import (
 	"context"
+	"fmt"
 	"path"
-	"path/filepath"
 
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
 	"github.com/grafana/grafana/pkg/services/provisioning/datasources"
-	ini "gopkg.in/ini.v1"
+	"github.com/grafana/grafana/pkg/setting"
 )
 
-func Init(ctx context.Context, homePath string, cfg *ini.File) error {
-	provisioningPath := makeAbsolute(cfg.Section("paths").Key("provisioning").String(), homePath)
+func init() {
+	registry.RegisterService(&ProvisioningService{})
+}
+
+type ProvisioningService struct {
+	Cfg *setting.Cfg `inject:""`
+}
 
-	datasourcePath := path.Join(provisioningPath, "datasources")
+func (ps *ProvisioningService) Init() error {
+	datasourcePath := path.Join(ps.Cfg.ProvisioningPath, "datasources")
 	if err := datasources.Provision(datasourcePath); err != nil {
-		return err
+		return fmt.Errorf("Datasource provisioning error: %v", err)
 	}
 
-	dashboardPath := path.Join(provisioningPath, "dashboards")
-	_, err := dashboards.Provision(ctx, dashboardPath)
-	return err
+	return nil
 }
 
-func makeAbsolute(path string, root string) string {
-	if filepath.IsAbs(path) {
-		return path
+func (ps *ProvisioningService) Run(ctx context.Context) error {
+	dashboardPath := path.Join(ps.Cfg.ProvisioningPath, "dashboards")
+	dashProvisioner := dashboards.NewDashboardProvisioner(dashboardPath)
+
+	if err := dashProvisioner.Provision(ctx); err != nil {
+		return err
 	}
-	return filepath.Join(root, path)
+
+	<-ctx.Done()
+	return ctx.Err()
 }

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

@@ -114,7 +114,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 	builder.Write(" ORDER BY name ASC")
 
 	if query.Limit != 0 {
-		builder.Write(" LIMIT ?", query.Limit)
+		builder.Write(dialect.Limit(query.Limit))
 	}
 
 	alerts := make([]*m.AlertListItemDTO, 0)

+ 0 - 2
pkg/services/sqlstore/alert_notification_test.go

@@ -1,7 +1,6 @@
 package sqlstore
 
 import (
-	"fmt"
 	"testing"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -21,7 +20,6 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			}
 
 			err := GetAlertNotifications(cmd)
-			fmt.Printf("error %v", err)
 			So(err, ShouldBeNil)
 			So(cmd.Result, ShouldBeNil)
 		})

+ 5 - 5
pkg/services/sqlstore/annotation.go

@@ -50,7 +50,7 @@ func (r *SqlAnnotationRepo) ensureTagsExist(sess *DBSession, tags []*models.Tag)
 		var existingTag models.Tag
 
 		// check if it exists
-		if exists, err := sess.Table("tag").Where("`key`=? AND `value`=?", tag.Key, tag.Value).Get(&existingTag); err != nil {
+		if exists, err := sess.Table("tag").Where(dialect.Quote("key")+"=? AND "+dialect.Quote("value")+"=?", tag.Key, tag.Value).Get(&existingTag); err != nil {
 			return nil, err
 		} else if exists {
 			tag.Id = existingTag.Id
@@ -146,7 +146,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 	params = append(params, query.OrgId)
 
 	if query.AnnotationId != 0 {
-		fmt.Print("annotation query")
+		// fmt.Print("annotation query")
 		sql.WriteString(` AND annotation.id = ?`)
 		params = append(params, query.AnnotationId)
 	}
@@ -193,10 +193,10 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 		tags := models.ParseTagPairs(query.Tags)
 		for _, tag := range tags {
 			if tag.Value == "" {
-				keyValueFilters = append(keyValueFilters, "(tag.key = ?)")
+				keyValueFilters = append(keyValueFilters, "(tag."+dialect.Quote("key")+" = ?)")
 				params = append(params, tag.Key)
 			} else {
-				keyValueFilters = append(keyValueFilters, "(tag.key = ? AND tag.value = ?)")
+				keyValueFilters = append(keyValueFilters, "(tag."+dialect.Quote("key")+" = ? AND tag."+dialect.Quote("value")+" = ?)")
 				params = append(params, tag.Key, tag.Value)
 			}
 		}
@@ -219,7 +219,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 		query.Limit = 100
 	}
 
-	sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit))
+	sql.WriteString(" ORDER BY epoch DESC" + dialect.Limit(query.Limit))
 
 	items := make([]*annotations.ItemDTO, 0)
 

+ 16 - 3
pkg/services/sqlstore/annotation_test.go

@@ -10,12 +10,18 @@ import (
 )
 
 func TestSavingTags(t *testing.T) {
+	InitTestDB(t)
+
 	Convey("Testing annotation saving/loading", t, func() {
-		InitTestDB(t)
 
 		repo := SqlAnnotationRepo{}
 
 		Convey("Can save tags", func() {
+			Reset(func() {
+				_, err := x.Exec("DELETE FROM annotation_tag WHERE 1=1")
+				So(err, ShouldBeNil)
+			})
+
 			tagPairs := []*models.Tag{
 				{Key: "outage"},
 				{Key: "type", Value: "outage"},
@@ -31,12 +37,19 @@ func TestSavingTags(t *testing.T) {
 }
 
 func TestAnnotations(t *testing.T) {
-	Convey("Testing annotation saving/loading", t, func() {
-		InitTestDB(t)
+	InitTestDB(t)
 
+	Convey("Testing annotation saving/loading", t, func() {
 		repo := SqlAnnotationRepo{}
 
 		Convey("Can save annotation", func() {
+			Reset(func() {
+				_, err := x.Exec("DELETE FROM annotation WHERE 1=1")
+				So(err, ShouldBeNil)
+				_, err = x.Exec("DELETE FROM annotation_tag WHERE 1=1")
+				So(err, ShouldBeNil)
+			})
+
 			annotation := &annotations.Item{
 				OrgId:       1,
 				UserId:      1,

+ 30 - 32
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,46 +109,43 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 }
 
 func TestDeleteExpiredSnapshots(t *testing.T) {
-	Convey("Testing dashboard snapshots clean up", t, func() {
-		x := InitTestDB(t)
+	sqlstore := InitTestDB(t)
 
+	Convey("Testing dashboard snapshots clean up", t, func() {
 		setting.SnapShotRemoveExpired = true
 
-		notExpiredsnapshot := createTestSnapshot(x, "key1", 1000)
-		createTestSnapshot(x, "key2", -1000)
-		createTestSnapshot(x, "key3", -1000)
+		notExpiredsnapshot := createTestSnapshot(sqlstore, "key1", 48000)
+		createTestSnapshot(sqlstore, "key2", -1200)
+		createTestSnapshot(sqlstore, "key3", -1200)
 
-		Convey("Clean up old dashboard snapshots", func() {
-			err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
-			So(err, ShouldBeNil)
+		err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
+		So(err, ShouldBeNil)
 
-			query := m.GetDashboardSnapshotsQuery{
-				OrgId:        1,
-				SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
-			}
-			err = SearchDashboardSnapshots(&query)
-			So(err, ShouldBeNil)
+		query := m.GetDashboardSnapshotsQuery{
+			OrgId:        1,
+			SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+		}
+		err = SearchDashboardSnapshots(&query)
+		So(err, ShouldBeNil)
 
-			So(len(query.Result), ShouldEqual, 1)
-			So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key)
-		})
+		So(len(query.Result), ShouldEqual, 1)
+		So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key)
 
-		Convey("Don't delete anything if there are no expired snapshots", func() {
-			err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
-			So(err, ShouldBeNil)
+		err = DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
+		So(err, ShouldBeNil)
 
-			query := m.GetDashboardSnapshotsQuery{
-				OrgId:        1,
-				SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
-			}
-			SearchDashboardSnapshots(&query)
+		query = m.GetDashboardSnapshotsQuery{
+			OrgId:        1,
+			SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+		}
+		SearchDashboardSnapshots(&query)
 
-			So(len(query.Result), ShouldEqual, 1)
-		})
+		So(len(query.Result), ShouldEqual, 1)
+		So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key)
 	})
 }
 
-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,
@@ -164,9 +160,11 @@ func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardS
 	So(err, ShouldBeNil)
 
 	// Set expiry date manually - to be able to create expired snapshots
-	expireDate := time.Now().Add(time.Second * time.Duration(expires))
-	_, err = x.Exec("update dashboard_snapshot set expires = ? where "+dialect.Quote("key")+" = ?", expireDate, key)
-	So(err, ShouldBeNil)
+	if expires < 0 {
+		expireDate := time.Now().Add(time.Second * time.Duration(expires))
+		_, err = sqlstore.engine.Exec("UPDATE dashboard_snapshot SET expires = ? WHERE id = ?", expireDate, cmd.Result.Id)
+		So(err, ShouldBeNil)
+	}
 
 	return cmd.Result
 }

+ 2 - 8
pkg/services/sqlstore/migrations/annotation_mig.go

@@ -86,10 +86,7 @@ func addAnnotationMig(mg *Migrator) {
 	// clear alert text
 	//
 	updateTextFieldSql := "UPDATE annotation SET TEXT = '' WHERE alert_id > 0"
-	mg.AddMigration("Update alert annotations and set TEXT to empty", new(RawSqlMigration).
-		Sqlite(updateTextFieldSql).
-		Postgres(updateTextFieldSql).
-		Mysql(updateTextFieldSql))
+	mg.AddMigration("Update alert annotations and set TEXT to empty", NewRawSqlMigration(updateTextFieldSql))
 
 	//
 	// Add a 'created' & 'updated' column
@@ -111,8 +108,5 @@ func addAnnotationMig(mg *Migrator) {
 	// Convert epoch saved as seconds to miliseconds
 	//
 	updateEpochSql := "UPDATE annotation SET epoch = (epoch*1000) where epoch < 9999999999"
-	mg.AddMigration("Convert existing annotations from seconds to milliseconds", new(RawSqlMigration).
-		Sqlite(updateEpochSql).
-		Postgres(updateEpochSql).
-		Mysql(updateEpochSql))
+	mg.AddMigration("Convert existing annotations from seconds to milliseconds", NewRawSqlMigration(updateEpochSql))
 }

+ 1 - 4
pkg/services/sqlstore/migrations/dashboard_acl.go

@@ -45,8 +45,5 @@ INSERT INTO dashboard_acl
 		(-1,-1, 2,'Editor','2017-06-20','2017-06-20')
 	`
 
-	mg.AddMigration("save default acl rules in dashboard_acl table", new(RawSqlMigration).
-		Sqlite(rawSQL).
-		Postgres(rawSQL).
-		Mysql(rawSQL))
+	mg.AddMigration("save default acl rules in dashboard_acl table", NewRawSqlMigration(rawSQL))
 }

+ 2 - 4
pkg/services/sqlstore/migrations/dashboard_mig.go

@@ -90,9 +90,7 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("drop table dashboard_v1", NewDropTableMigration("dashboard_v1"))
 
 	// change column type of dashboard.data
-	mg.AddMigration("alter dashboard.data to mediumtext v1", new(RawSqlMigration).
-		Sqlite("SELECT 0 WHERE 0;").
-		Postgres("SELECT 0;").
+	mg.AddMigration("alter dashboard.data to mediumtext v1", NewRawSqlMigration("").
 		Mysql("ALTER TABLE dashboard MODIFY data MEDIUMTEXT;"))
 
 	// add column to store updater of a dashboard
@@ -157,7 +155,7 @@ func addDashboardMigration(mg *Migrator) {
 		Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
 	}))
 
-	mg.AddMigration("Update uid column values in dashboard", new(RawSqlMigration).
+	mg.AddMigration("Update uid column values in dashboard", NewRawSqlMigration("").
 		Sqlite("UPDATE dashboard SET uid=printf('%09d',id) WHERE uid IS NULL;").
 		Postgres("UPDATE dashboard SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;").
 		Mysql("UPDATE dashboard SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))

+ 1 - 3
pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go

@@ -50,9 +50,7 @@ func addDashboardSnapshotMigrations(mg *Migrator) {
 	addTableIndicesMigrations(mg, "v5", snapshotV5)
 
 	// change column type of dashboard
-	mg.AddMigration("alter dashboard_snapshot to mediumtext v2", new(RawSqlMigration).
-		Sqlite("SELECT 0 WHERE 0;").
-		Postgres("SELECT 0;").
+	mg.AddMigration("alter dashboard_snapshot to mediumtext v2", NewRawSqlMigration("").
 		Mysql("ALTER TABLE dashboard_snapshot MODIFY dashboard MEDIUMTEXT;"))
 
 	mg.AddMigration("Update dashboard_snapshot table charset", NewTableCharsetMigration("dashboard_snapshot", []*Column{

+ 3 - 11
pkg/services/sqlstore/migrations/dashboard_version_mig.go

@@ -28,10 +28,7 @@ func addDashboardVersionMigration(mg *Migrator) {
 
 	// before new dashboards where created with version 0, now they are always inserted with version 1
 	const setVersionTo1WhereZeroSQL = `UPDATE dashboard SET version = 1 WHERE version = 0`
-	mg.AddMigration("Set dashboard version to 1 where 0", new(RawSqlMigration).
-		Sqlite(setVersionTo1WhereZeroSQL).
-		Postgres(setVersionTo1WhereZeroSQL).
-		Mysql(setVersionTo1WhereZeroSQL))
+	mg.AddMigration("Set dashboard version to 1 where 0", NewRawSqlMigration(setVersionTo1WhereZeroSQL))
 
 	const rawSQL = `INSERT INTO dashboard_version
 (
@@ -54,14 +51,9 @@ SELECT
 	'',
 	dashboard.data
 FROM dashboard;`
-	mg.AddMigration("save existing dashboard data in dashboard_version table v1", new(RawSqlMigration).
-		Sqlite(rawSQL).
-		Postgres(rawSQL).
-		Mysql(rawSQL))
+	mg.AddMigration("save existing dashboard data in dashboard_version table v1", NewRawSqlMigration(rawSQL))
 
 	// change column type of dashboard_version.data
-	mg.AddMigration("alter dashboard_version.data to mediumtext v1", new(RawSqlMigration).
-		Sqlite("SELECT 0 WHERE 0;").
-		Postgres("SELECT 0;").
+	mg.AddMigration("alter dashboard_version.data to mediumtext v1", NewRawSqlMigration("").
 		Mysql("ALTER TABLE dashboard_version MODIFY data MEDIUMTEXT;"))
 }

+ 1 - 4
pkg/services/sqlstore/migrations/datasource_mig.go

@@ -122,10 +122,7 @@ func addDataSourceMigration(mg *Migrator) {
 	}))
 
 	const setVersionToOneWhereZero = `UPDATE data_source SET version = 1 WHERE version = 0`
-	mg.AddMigration("Update initial version to 1", new(RawSqlMigration).
-		Sqlite(setVersionToOneWhereZero).
-		Postgres(setVersionToOneWhereZero).
-		Mysql(setVersionToOneWhereZero))
+	mg.AddMigration("Update initial version to 1", NewRawSqlMigration(setVersionToOneWhereZero))
 
 	mg.AddMigration("Add read_only data column", NewAddColumnMigration(tableV2, &Column{
 		Name: "read_only", Type: DB_Bool, Nullable: true,

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

@@ -25,7 +25,7 @@ func TestMigrations(t *testing.T) {
 			x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
 			So(err, ShouldBeNil)
 
-			sqlutil.CleanDB(x)
+			NewDialect(x).CleanDB()
 
 			_, err = x.SQL(sql).Get(&r)
 			So(err, ShouldNotBeNil)
@@ -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)

+ 1 - 25
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},
@@ -85,8 +64,5 @@ func addOrgMigrations(mg *Migrator) {
 	}))
 
 	const migrateReadOnlyViewersToViewers = `UPDATE org_user SET role = 'Viewer' WHERE role = 'Read Only Editor'`
-	mg.AddMigration("Migrate all Read Only Viewers to Viewers", new(RawSqlMigration).
-		Sqlite(migrateReadOnlyViewersToViewers).
-		Postgres(migrateReadOnlyViewersToViewers).
-		Mysql(migrateReadOnlyViewersToViewers))
+	mg.AddMigration("Migrate all Read Only Viewers to Viewers", NewRawSqlMigration(migrateReadOnlyViewersToViewers))
 }

+ 3 - 4
pkg/services/sqlstore/migrations/user_auth_mig.go

@@ -22,8 +22,7 @@ func addUserAuthMigrations(mg *Migrator) {
 	// add indices
 	addTableIndicesMigrations(mg, "v1", userAuthV1)
 
-	mg.AddMigration("alter user_auth.auth_id to length 255", new(RawSqlMigration).
-		Sqlite("SELECT 0 WHERE 0;").
-		Postgres("ALTER TABLE user_auth ALTER COLUMN auth_id TYPE VARCHAR(255);").
-		Mysql("ALTER TABLE user_auth MODIFY auth_id VARCHAR(255);"))
+	mg.AddMigration("alter user_auth.auth_id to length 190", NewRawSqlMigration("").
+		Postgres("ALTER TABLE user_auth ALTER COLUMN auth_id TYPE VARCHAR(190);").
+		Mysql("ALTER TABLE user_auth MODIFY auth_id VARCHAR(190);"))
 }

+ 2 - 41
pkg/services/sqlstore/migrator/column.go

@@ -15,48 +15,9 @@ type Column struct {
 }
 
 func (col *Column) String(d Dialect) string {
-	sql := d.QuoteStr() + col.Name + d.QuoteStr() + " "
-
-	sql += d.SqlType(col) + " "
-
-	if col.IsPrimaryKey {
-		sql += "PRIMARY KEY "
-		if col.IsAutoIncrement {
-			sql += d.AutoIncrStr() + " "
-		}
-	}
-
-	if d.ShowCreateNull() {
-		if col.Nullable {
-			sql += "NULL "
-		} else {
-			sql += "NOT NULL "
-		}
-	}
-
-	if col.Default != "" {
-		sql += "DEFAULT " + col.Default + " "
-	}
-
-	return sql
+	return d.ColString(col)
 }
 
 func (col *Column) StringNoPk(d Dialect) string {
-	sql := d.QuoteStr() + col.Name + d.QuoteStr() + " "
-
-	sql += d.SqlType(col) + " "
-
-	if d.ShowCreateNull() {
-		if col.Nullable {
-			sql += "NULL "
-		} else {
-			sql += "NOT NULL "
-		}
-	}
-
-	if col.Default != "" {
-		sql += "DEFAULT " + d.Default(col) + " "
-	}
-
-	return sql
+	return d.ColStringNoPk(col)
 }

+ 103 - 11
pkg/services/sqlstore/migrator/dialect.go

@@ -3,11 +3,12 @@ package migrator
 import (
 	"fmt"
 	"strings"
+
+	"github.com/go-xorm/xorm"
 )
 
 type Dialect interface {
 	DriverName() string
-	QuoteStr() string
 	Quote(string) string
 	AndStr() string
 	AutoIncrStr() string
@@ -31,16 +32,29 @@ type Dialect interface {
 	TableCheckSql(tableName string) (string, []interface{})
 	RenameTable(oldName string, newName string) string
 	UpdateTableSql(tableName string, columns []*Column) string
+
+	ColString(*Column) string
+	ColStringNoPk(*Column) string
+
+	Limit(limit int64) string
+	LimitOffset(limit int64, offset int64) string
+
+	PreInsertId(table string, sess *xorm.Session) error
+	PostInsertId(table string, sess *xorm.Session) error
+
+	CleanDB() error
+	NoOpSql() string
 }
 
-func NewDialect(name string) Dialect {
+func NewDialect(engine *xorm.Engine) Dialect {
+	name := engine.DriverName()
 	switch name {
 	case MYSQL:
-		return NewMysqlDialect()
+		return NewMysqlDialect(engine)
 	case SQLITE:
-		return NewSqlite3Dialect()
+		return NewSqlite3Dialect(engine)
 	case POSTGRES:
-		return NewPostgresDialect()
+		return NewPostgresDialect(engine)
 	}
 
 	panic("Unsupported database type: " + name)
@@ -48,6 +62,7 @@ func NewDialect(name string) Dialect {
 
 type BaseDialect struct {
 	dialect    Dialect
+	engine     *xorm.Engine
 	driverName string
 }
 
@@ -100,9 +115,12 @@ func (b *BaseDialect) CreateTableSql(table *Table) string {
 	}
 
 	if len(pkList) > 1 {
-		sql += "PRIMARY KEY ( "
-		sql += b.dialect.Quote(strings.Join(pkList, b.dialect.Quote(",")))
-		sql += " ), "
+		quotedCols := []string{}
+		for _, col := range pkList {
+			quotedCols = append(quotedCols, b.dialect.Quote(col))
+		}
+
+		sql += "PRIMARY KEY ( " + strings.Join(quotedCols, ",") + " ), "
 	}
 
 	sql = sql[:len(sql)-2] + ")"
@@ -127,9 +145,12 @@ func (db *BaseDialect) CreateIndexSql(tableName string, index *Index) string {
 
 	idxName := index.XName(tableName)
 
-	return fmt.Sprintf("CREATE%s INDEX %v ON %v (%v);", unique,
-		quote(idxName), quote(tableName),
-		quote(strings.Join(index.Cols, quote(","))))
+	quotedCols := []string{}
+	for _, col := range index.Cols {
+		quotedCols = append(quotedCols, db.dialect.Quote(col))
+	}
+
+	return fmt.Sprintf("CREATE%s INDEX %v ON %v (%v);", unique, quote(idxName), quote(tableName), strings.Join(quotedCols, ","))
 }
 
 func (db *BaseDialect) QuoteColList(cols []string) string {
@@ -168,3 +189,74 @@ func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string {
 func (db *BaseDialect) UpdateTableSql(tableName string, columns []*Column) string {
 	return "-- NOT REQUIRED"
 }
+
+func (db *BaseDialect) ColString(col *Column) string {
+	sql := db.dialect.Quote(col.Name) + " "
+
+	sql += db.dialect.SqlType(col) + " "
+
+	if col.IsPrimaryKey {
+		sql += "PRIMARY KEY "
+		if col.IsAutoIncrement {
+			sql += db.dialect.AutoIncrStr() + " "
+		}
+	}
+
+	if db.dialect.ShowCreateNull() {
+		if col.Nullable {
+			sql += "NULL "
+		} else {
+			sql += "NOT NULL "
+		}
+	}
+
+	if col.Default != "" {
+		sql += "DEFAULT " + db.dialect.Default(col) + " "
+	}
+
+	return sql
+}
+
+func (db *BaseDialect) ColStringNoPk(col *Column) string {
+	sql := db.dialect.Quote(col.Name) + " "
+
+	sql += db.dialect.SqlType(col) + " "
+
+	if db.dialect.ShowCreateNull() {
+		if col.Nullable {
+			sql += "NULL "
+		} else {
+			sql += "NOT NULL "
+		}
+	}
+
+	if col.Default != "" {
+		sql += "DEFAULT " + db.dialect.Default(col) + " "
+	}
+
+	return sql
+}
+
+func (db *BaseDialect) Limit(limit int64) string {
+	return fmt.Sprintf(" LIMIT %d", limit)
+}
+
+func (db *BaseDialect) LimitOffset(limit int64, offset int64) string {
+	return fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset)
+}
+
+func (db *BaseDialect) PreInsertId(table string, sess *xorm.Session) error {
+	return nil
+}
+
+func (db *BaseDialect) PostInsertId(table string, sess *xorm.Session) error {
+	return nil
+}
+
+func (db *BaseDialect) CleanDB() error {
+	return nil
+}
+
+func (db *BaseDialect) NoOpSql() string {
+	return "SELECT 0;"
+}

+ 38 - 17
pkg/services/sqlstore/migrator/migrations.go

@@ -24,37 +24,58 @@ func (m *MigrationBase) GetCondition() MigrationCondition {
 type RawSqlMigration struct {
 	MigrationBase
 
-	sqlite   string
-	mysql    string
-	postgres string
+	sql map[string]string
+}
+
+func NewRawSqlMigration(sql string) *RawSqlMigration {
+	m := &RawSqlMigration{}
+	if sql != "" {
+		m.Default(sql)
+	}
+	return m
 }
 
 func (m *RawSqlMigration) Sql(dialect Dialect) string {
-	switch dialect.DriverName() {
-	case MYSQL:
-		return m.mysql
-	case SQLITE:
-		return m.sqlite
-	case POSTGRES:
-		return m.postgres
+	if m.sql != nil {
+		if val := m.sql[dialect.DriverName()]; val != "" {
+			return val
+		}
+
+		if val := m.sql["default"]; val != "" {
+			return val
+		}
 	}
 
-	panic("db type not supported")
+	return dialect.NoOpSql()
 }
 
-func (m *RawSqlMigration) Sqlite(sql string) *RawSqlMigration {
-	m.sqlite = sql
+func (m *RawSqlMigration) Set(dialect string, sql string) *RawSqlMigration {
+	if m.sql == nil {
+		m.sql = make(map[string]string)
+	}
+
+	m.sql[dialect] = sql
 	return m
 }
 
+func (m *RawSqlMigration) Default(sql string) *RawSqlMigration {
+	return m.Set("default", sql)
+}
+
+func (m *RawSqlMigration) Sqlite(sql string) *RawSqlMigration {
+	return m.Set(SQLITE, sql)
+}
+
 func (m *RawSqlMigration) Mysql(sql string) *RawSqlMigration {
-	m.mysql = sql
-	return m
+	return m.Set(MYSQL, sql)
 }
 
 func (m *RawSqlMigration) Postgres(sql string) *RawSqlMigration {
-	m.postgres = sql
-	return m
+	return m.Set(POSTGRES, sql)
+}
+
+func (m *RawSqlMigration) Mssql(sql string) *RawSqlMigration {
+	return m.Set(MSSQL, sql)
 }
 
 type AddColumnMigration struct {

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

@@ -31,7 +31,7 @@ func NewMigrator(engine *xorm.Engine) *Migrator {
 	mg.x = engine
 	mg.Logger = log.New("migrator")
 	mg.migrations = make([]Migration, 0)
-	mg.dialect = NewDialect(mg.x.DriverName())
+	mg.dialect = NewDialect(mg.x)
 	return mg
 }
 
@@ -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()
 		}
 	}

+ 25 - 5
pkg/services/sqlstore/migrator/mysql_dialect.go

@@ -1,17 +1,21 @@
 package migrator
 
 import (
+	"fmt"
 	"strconv"
 	"strings"
+
+	"github.com/go-xorm/xorm"
 )
 
 type Mysql struct {
 	BaseDialect
 }
 
-func NewMysqlDialect() *Mysql {
+func NewMysqlDialect(engine *xorm.Engine) *Mysql {
 	d := Mysql{}
 	d.BaseDialect.dialect = &d
+	d.BaseDialect.engine = engine
 	d.BaseDialect.driverName = MYSQL
 	return &d
 }
@@ -24,10 +28,6 @@ func (db *Mysql) Quote(name string) string {
 	return "`" + name + "`"
 }
 
-func (db *Mysql) QuoteStr() string {
-	return "`"
-}
-
 func (db *Mysql) AutoIncrStr() string {
 	return "AUTO_INCREMENT"
 }
@@ -105,3 +105,23 @@ func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string {
 
 	return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";"
 }
+
+func (db *Mysql) CleanDB() error {
+	tables, _ := db.engine.DBMetas()
+	sess := db.engine.NewSession()
+	defer sess.Close()
+
+	for _, table := range tables {
+		if _, err := sess.Exec("set foreign_key_checks = 0"); err != nil {
+			return fmt.Errorf("failed to disable foreign key checks")
+		}
+		if _, err := sess.Exec("drop table " + table.Name + " ;"); err != nil {
+			return fmt.Errorf("failed to delete table: %v, err: %v", table.Name, err)
+		}
+		if _, err := sess.Exec("set foreign_key_checks = 1"); err != nil {
+			return fmt.Errorf("failed to disable foreign key checks")
+		}
+	}
+
+	return nil
+}

+ 20 - 6
pkg/services/sqlstore/migrator/postgres_dialect.go

@@ -4,15 +4,18 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+
+	"github.com/go-xorm/xorm"
 )
 
 type Postgres struct {
 	BaseDialect
 }
 
-func NewPostgresDialect() *Postgres {
+func NewPostgresDialect(engine *xorm.Engine) *Postgres {
 	d := Postgres{}
 	d.BaseDialect.dialect = &d
+	d.BaseDialect.engine = engine
 	d.BaseDialect.driverName = POSTGRES
 	return &d
 }
@@ -25,10 +28,6 @@ func (db *Postgres) Quote(name string) string {
 	return "\"" + name + "\""
 }
 
-func (db *Postgres) QuoteStr() string {
-	return "\""
-}
-
 func (b *Postgres) LikeStr() string {
 	return "ILIKE"
 }
@@ -117,8 +116,23 @@ func (db *Postgres) UpdateTableSql(tableName string, columns []*Column) string {
 	var statements = []string{}
 
 	for _, col := range columns {
-		statements = append(statements, "ALTER "+db.QuoteStr()+col.Name+db.QuoteStr()+" TYPE "+db.SqlType(col))
+		statements = append(statements, "ALTER "+db.Quote(col.Name)+" TYPE "+db.SqlType(col))
 	}
 
 	return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";"
 }
+
+func (db *Postgres) CleanDB() error {
+	sess := db.engine.NewSession()
+	defer sess.Close()
+
+	if _, err := sess.Exec("DROP SCHEMA public CASCADE;"); err != nil {
+		return fmt.Errorf("Failed to drop schema public")
+	}
+
+	if _, err := sess.Exec("CREATE SCHEMA public;"); err != nil {
+		return fmt.Errorf("Failed to create schema public")
+	}
+
+	return nil
+}

+ 11 - 6
pkg/services/sqlstore/migrator/sqlite_dialect.go

@@ -1,14 +1,19 @@
 package migrator
 
-import "fmt"
+import (
+	"fmt"
+
+	"github.com/go-xorm/xorm"
+)
 
 type Sqlite3 struct {
 	BaseDialect
 }
 
-func NewSqlite3Dialect() *Sqlite3 {
+func NewSqlite3Dialect(engine *xorm.Engine) *Sqlite3 {
 	d := Sqlite3{}
 	d.BaseDialect.dialect = &d
+	d.BaseDialect.engine = engine
 	d.BaseDialect.driverName = SQLITE
 	return &d
 }
@@ -21,10 +26,6 @@ func (db *Sqlite3) Quote(name string) string {
 	return "`" + name + "`"
 }
 
-func (db *Sqlite3) QuoteStr() string {
-	return "`"
-}
-
 func (db *Sqlite3) AutoIncrStr() string {
 	return "AUTOINCREMENT"
 }
@@ -77,3 +78,7 @@ func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
 	idxName := index.XName(tableName)
 	return fmt.Sprintf("DROP INDEX %v", quote(idxName))
 }
+
+func (db *Sqlite3) CleanDB() error {
+	return nil
+}

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

@@ -9,6 +9,7 @@ const (
 	POSTGRES = "postgres"
 	SQLITE   = "sqlite3"
 	MYSQL    = "mysql"
+	MSSQL    = "mssql"
 )
 
 type Migration interface {

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

@@ -64,7 +64,7 @@ func UpdatePlaylist(cmd *m.UpdatePlaylistCommand) error {
 		Interval: playlist.Interval,
 	}
 
-	_, err := x.ID(cmd.Id).Cols("id", "name", "interval").Update(&playlist)
+	_, err := x.ID(cmd.Id).Cols("name", "interval").Update(&playlist)
 
 	if err != nil {
 		return err

+ 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

+ 3 - 5
pkg/services/sqlstore/search_builder.go

@@ -92,7 +92,7 @@ func (sb *SearchBuilder) ToSql() (string, []interface{}) {
 		LEFT OUTER JOIN dashboard folder on folder.id = dashboard.folder_id
 		LEFT OUTER JOIN dashboard_tag on dashboard.id = dashboard_tag.dashboard_id`)
 
-	sb.sql.WriteString(" ORDER BY dashboard.title ASC LIMIT 5000")
+	sb.sql.WriteString(" ORDER BY dashboard.title ASC" + dialect.Limit(5000))
 
 	return sb.sql.String(), sb.params
 }
@@ -135,12 +135,11 @@ func (sb *SearchBuilder) buildTagQuery() {
 	// this ends the inner select (tag filtered part)
 	sb.sql.WriteString(`
 		GROUP BY dashboard.id HAVING COUNT(dashboard.id) >= ?
-		LIMIT ?) as ids
+		ORDER BY dashboard.id` + dialect.Limit(int64(sb.limit)) + `) as ids
 		INNER JOIN dashboard on ids.id = dashboard.id
 	`)
 
 	sb.params = append(sb.params, len(sb.tags))
-	sb.params = append(sb.params, sb.limit)
 }
 
 func (sb *SearchBuilder) buildMainQuery() {
@@ -153,8 +152,7 @@ func (sb *SearchBuilder) buildMainQuery() {
 	sb.sql.WriteString(` WHERE `)
 	sb.buildSearchWhereClause()
 
-	sb.sql.WriteString(` LIMIT ?) as ids INNER JOIN dashboard on ids.id = dashboard.id `)
-	sb.params = append(sb.params, sb.limit)
+	sb.sql.WriteString(` ORDER BY dashboard.title` + dialect.Limit(int64(sb.limit)) + `) as ids INNER JOIN dashboard on ids.id = dashboard.id `)
 }
 
 func (sb *SearchBuilder) buildSearchWhereClause() {

+ 2 - 5
pkg/services/sqlstore/search_builder_test.go

@@ -4,13 +4,10 @@ import (
 	"testing"
 
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
 func TestSearchBuilder(t *testing.T) {
-	dialect = migrator.NewDialect("sqlite3")
-
 	Convey("Testing building a search", t, func() {
 		signedInUser := &m.SignedInUser{
 			OrgId:  1,
@@ -23,7 +20,7 @@ func TestSearchBuilder(t *testing.T) {
 			sql, params := sb.IsStarred().WithTitle("test").ToSql()
 			So(sql, ShouldStartWith, "SELECT")
 			So(sql, ShouldContainSubstring, "INNER JOIN dashboard on ids.id = dashboard.id")
-			So(sql, ShouldEndWith, "ORDER BY dashboard.title ASC LIMIT 5000")
+			So(sql, ShouldContainSubstring, "ORDER BY dashboard.title ASC")
 			So(len(params), ShouldBeGreaterThan, 0)
 		})
 
@@ -31,7 +28,7 @@ func TestSearchBuilder(t *testing.T) {
 			sql, params := sb.WithTags([]string{"tag1", "tag2"}).ToSql()
 			So(sql, ShouldStartWith, "SELECT")
 			So(sql, ShouldContainSubstring, "LEFT OUTER JOIN dashboard_tag")
-			So(sql, ShouldEndWith, "ORDER BY dashboard.title ASC LIMIT 5000")
+			So(sql, ShouldContainSubstring, "ORDER BY dashboard.title ASC")
 			So(len(params), ShouldBeGreaterThan, 0)
 		})
 	})

+ 21 - 0
pkg/services/sqlstore/shared.go

@@ -1,6 +1,7 @@
 package sqlstore
 
 import (
+	"reflect"
 	"time"
 
 	"github.com/go-xorm/xorm"
@@ -67,3 +68,23 @@ func inTransactionWithRetry(callback dbTransactionFunc, retry int) error {
 
 	return nil
 }
+
+func (sess *DBSession) InsertId(bean interface{}) (int64, error) {
+	table := sess.DB().Mapper.Obj2Table(getTypeName(bean))
+
+	dialect.PreInsertId(table, sess.Session)
+
+	id, err := sess.Session.InsertOne(bean)
+
+	dialect.PostInsertId(table, sess.Session)
+
+	return id, err
+}
+
+func getTypeName(bean interface{}) (res string) {
+	t := reflect.TypeOf(bean)
+	for t.Kind() == reflect.Ptr {
+		t = t.Elem()
+	}
+	return t.Name()
+}

+ 167 - 144
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"
@@ -20,7 +21,6 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 
 	"github.com/go-sql-driver/mysql"
-	_ "github.com/go-sql-driver/mysql"
 	"github.com/go-xorm/xorm"
 	_ "github.com/lib/pq"
 	_ "github.com/mattn/go-sqlite3"
@@ -28,39 +28,72 @@ 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
+	sqlog log.Logger = log.New("sqlstore")
+)
+
+func init() {
+	registry.Register(&registry.Descriptor{
+		Name:         "SqlStore",
+		Instance:     &SqlStore{},
+		InitPriority: registry.High,
+	})
+}
 
-	DbCfg DatabaseConfig
+type SqlStore struct {
+	Cfg *setting.Cfg `inject:""`
 
-	UseSQLite3 bool
-	sqlog      log.Logger = log.New("sqlstore")
-)
+	dbCfg           DatabaseConfig
+	engine          *xorm.Engine
+	log             log.Logger
+	skipEnsureAdmin bool
+}
+
+func (ss *SqlStore) Init() error {
+	ss.log = log.New("sqlstore")
+	ss.readConfig()
+
+	engine, err := ss.getEngine()
 
-func EnsureAdminUser() {
+	if err != nil {
+		return fmt.Errorf("Fail to connect to database: %v", err)
+	}
+
+	ss.engine = engine
+
+	// 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("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 {
-		log.Fatal(3, "Could not determine if admin user exists: %v", err)
-		return
+		fmt.Errorf("Could not determine if admin user exists: %v", err)
 	}
 
 	if statsQuery.Result.Users > 0 {
-		return
+		return nil
 	}
 
 	cmd := m.CreateUserCommand{}
@@ -70,105 +103,89 @@ func EnsureAdminUser() {
 	cmd.IsAdmin = true
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		log.Error(3, "Failed to create default admin user", err)
-		return
+		return fmt.Errorf("Failed to create admin user: %v", err)
 	}
 
-	log.Info("Created default admin user: %v", setting.AdminUser)
-}
-
-func NewEngine() *xorm.Engine {
-	x, err := getEngine()
-
-	if err != nil {
-		sqlog.Crit("Fail to connect to database", "error", err)
-		os.Exit(1)
-	}
-
-	err = SetEngine(x)
+	ss.log.Info("Created default admin user: %v", setting.AdminUser)
 
-	if err != nil {
-		sqlog.Error("Fail to initialize orm engine", "error", err)
-		os.Exit(1)
-	}
-
-	return x
+	return nil
 }
 
-func SetEngine(engine *xorm.Engine) (err error) {
-	x = engine
-	dialect = migrator.NewDialect(x.DriverName())
+func (ss *SqlStore) buildConnectionString() (string, error) {
+	cnnstr := ss.dbCfg.ConnectionString
 
-	migrator := migrator.NewMigrator(x)
-	migrations.AddMigrations(migrator)
-
-	if err := migrator.Start(); err != nil {
-		return fmt.Errorf("Sqlstore::Migration failed err: %v\n", err)
+	// special case used by integration tests
+	if cnnstr != "" {
+		return cnnstr, nil
 	}
 
-	// Init repo instances
-	annotations.SetRepository(&SqlAnnotationRepo{})
-	return nil
-}
-
-func getEngine() (*xorm.Engine, error) {
-	LoadConfig()
-
-	cnnstr := ""
-	switch DbCfg.Type {
-	case "mysql":
+	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",
-			DbCfg.User, DbCfg.Pwd, protocol, DbCfg.Host, 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 "postgres":
+	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]
 		}
-		if DbCfg.Pwd == "" {
-			DbCfg.Pwd = "''"
+		if ss.dbCfg.Pwd == "" {
+			ss.dbCfg.Pwd = "''"
 		}
-		if DbCfg.User == "" {
-			DbCfg.User = "''"
+		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", DbCfg.User, DbCfg.Pwd, host, port, DbCfg.Name, DbCfg.SslMode, DbCfg.ClientCertPath, DbCfg.ClientKeyPath, DbCfg.CaCertPath)
-	case "sqlite3":
-		if !filepath.IsAbs(DbCfg.Path) {
-			DbCfg.Path = filepath.Join(setting.DataPath, DbCfg.Path)
+		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:
+		// 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 {
@@ -180,101 +197,95 @@ 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()
-		}
+		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.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
-	}
-	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")
-}
 
-var (
-	dbSqlite   = "sqlite"
-	dbMySql    = "mysql"
-	dbPostgres = "postgres"
-)
+	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 := dbSqlite
-	// selectedDb := dbMySql
-	// selectedDb := dbPostgres
+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 dbMySql:
-		x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
-	case dbPostgres:
-		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
-
-	// x.ShowSQL()
-
+	// 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)
 	}
 
-	sqlutil.CleanDB(x)
+	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)
 	}
 
-	return x
+	//// sqlstore.engine.DatabaseTZ = time.UTC
+	//// sqlstore.engine.TZLocation = time.UTC
+
+	return sqlstore
 }
 
 func IsTestDbMySql() bool {
 	if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present {
-		return db == dbMySql
+		return db == migrator.MYSQL
 	}
 
 	return false
@@ -282,8 +293,20 @@ func IsTestDbMySql() bool {
 
 func IsTestDbPostgres() bool {
 	if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present {
-		return db == dbPostgres
+		return db == migrator.POSTGRES
 	}
 
 	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
+}

+ 0 - 37
pkg/services/sqlstore/sqlutil/sqlutil.go

@@ -1,11 +1,5 @@
 package sqlutil
 
-import (
-	"fmt"
-
-	"github.com/go-xorm/xorm"
-)
-
 type TestDB struct {
 	DriverName string
 	ConnStr    string
@@ -15,34 +9,3 @@ var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:"}
 var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"}
 var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"}
 var TestDB_Mssql = TestDB{DriverName: "mssql", ConnStr: "server=localhost;port=1433;database=grafanatest;user id=grafana;password=Password!"}
-
-func CleanDB(x *xorm.Engine) {
-	if x.DriverName() == "postgres" {
-		sess := x.NewSession()
-		defer sess.Close()
-
-		if _, err := sess.Exec("DROP SCHEMA public CASCADE;"); err != nil {
-			panic("Failed to drop schema public")
-		}
-
-		if _, err := sess.Exec("CREATE SCHEMA public;"); err != nil {
-			panic("Failed to create schema public")
-		}
-	} else if x.DriverName() == "mysql" {
-		tables, _ := x.DBMetas()
-		sess := x.NewSession()
-		defer sess.Close()
-
-		for _, table := range tables {
-			if _, err := sess.Exec("set foreign_key_checks = 0"); err != nil {
-				panic("failed to disable foreign key checks")
-			}
-			if _, err := sess.Exec("drop table " + table.Name + " ;"); err != nil {
-				panic(fmt.Sprintf("failed to delete table: %v, err: %v", table.Name, err))
-			}
-			if _, err := sess.Exec("set foreign_key_checks = 1"); err != nil {
-				panic("failed to disable foreign key checks")
-			}
-		}
-	}
-}

+ 1 - 2
pkg/services/sqlstore/team.go

@@ -161,9 +161,8 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 	sql.WriteString(` order by team.name asc`)
 
 	if query.Limit != 0 {
-		sql.WriteString(` limit ? offset ?`)
 		offset := query.Limit * (query.Page - 1)
-		params = append(params, query.Limit, offset)
+		sql.WriteString(dialect.LimitOffset(int64(query.Limit), int64(offset)))
 	}
 
 	if err := x.Sql(sql.String(), params...).Find(&query.Result.Teams); err != nil {

+ 8 - 2
pkg/services/sqlstore/user.go

@@ -60,8 +60,14 @@ func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error
 	org.Created = time.Now()
 	org.Updated = time.Now()
 
-	if _, err := sess.Insert(&org); err != nil {
-		return 0, err
+	if org.Id != 0 {
+		if _, err := sess.InsertId(&org); err != nil {
+			return 0, err
+		}
+	} else {
+		if _, err := sess.InsertOne(&org); err != nil {
+			return 0, err
+		}
 	}
 
 	sess.publishAfterCommit(&events.OrgCreated{

+ 22 - 9
pkg/setting/setting.go

@@ -52,12 +52,11 @@ var (
 	ApplicationName string
 
 	// Paths
-	LogsPath         string
-	HomePath         string
-	DataPath         string
-	PluginsPath      string
-	ProvisioningPath string
-	CustomInitPath   = "conf/custom.ini"
+	LogsPath       string
+	HomePath       string
+	DataPath       string
+	PluginsPath    string
+	CustomInitPath = "conf/custom.ini"
 
 	// Log settings.
 	LogModes   []string
@@ -125,6 +124,7 @@ var (
 	AuthProxyAutoSignUp     bool
 	AuthProxyLdapSyncTtl    int
 	AuthProxyWhitelist      string
+	AuthProxyHeaders        map[string]string
 
 	// Basic Auth
 	BasicAuthEnabled bool
@@ -187,6 +187,9 @@ var (
 type Cfg struct {
 	Raw *ini.File
 
+	// Paths
+	ProvisioningPath string
+
 	// SMTP email settings
 	Smtp SmtpSettings
 
@@ -492,7 +495,9 @@ func validateStaticRootPath() error {
 }
 
 func NewCfg() *Cfg {
-	return &Cfg{}
+	return &Cfg{
+		Raw: ini.Empty(),
+	}
 }
 
 func (cfg *Cfg) Load(args *CommandLineArgs) error {
@@ -516,7 +521,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
-	ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
+	cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
 	server := iniFile.Section("server")
 	AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
 
@@ -611,6 +616,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	AuthProxyLdapSyncTtl = authProxy.Key("ldap_sync_ttl").MustInt()
 	AuthProxyWhitelist = authProxy.Key("whitelist").String()
 
+	AuthProxyHeaders = make(map[string]string)
+	for _, propertyAndHeader := range util.SplitString(authProxy.Key("headers").String()) {
+		split := strings.SplitN(propertyAndHeader, ":", 2)
+		if len(split) == 2 {
+			AuthProxyHeaders[split[0]] = split[1]
+		}
+	}
+
 	// basic auth
 	authBasic := iniFile.Section("auth.basic")
 	BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
@@ -719,6 +732,6 @@ func (cfg *Cfg) LogConfigSources() {
 	logger.Info("Path Data", "path", DataPath)
 	logger.Info("Path Logs", "path", LogsPath)
 	logger.Info("Path Plugins", "path", PluginsPath)
-	logger.Info("Path Provisioning", "path", ProvisioningPath)
+	logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
 	logger.Info("App mode " + Env)
 }

+ 55 - 43
pkg/tracing/tracing.go

@@ -1,68 +1,71 @@
 package tracing
 
 import (
+	"context"
 	"io"
 	"strings"
 
 	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/setting"
 
 	opentracing "github.com/opentracing/opentracing-go"
 	jaegercfg "github.com/uber/jaeger-client-go/config"
-	ini "gopkg.in/ini.v1"
 )
 
-var (
-	logger log.Logger = log.New("tracing")
-)
-
-type TracingSettings struct {
-	Enabled      bool
-	Address      string
-	CustomTags   map[string]string
-	SamplerType  string
-	SamplerParam float64
+func init() {
+	registry.RegisterService(&TracingService{})
 }
 
-func Init(file *ini.File) (io.Closer, error) {
-	settings := parseSettings(file)
-	return internalInit(settings)
+type TracingService struct {
+	enabled      bool
+	address      string
+	customTags   map[string]string
+	samplerType  string
+	samplerParam float64
+	log          log.Logger
+	closer       io.Closer
+
+	Cfg *setting.Cfg `inject:""`
 }
 
-func parseSettings(file *ini.File) *TracingSettings {
-	settings := &TracingSettings{}
+func (ts *TracingService) Init() error {
+	ts.log = log.New("tracing")
+	ts.parseSettings()
 
-	var section, err = setting.Raw.GetSection("tracing.jaeger")
-	if err != nil {
-		return settings
+	if ts.enabled {
+		ts.initGlobalTracer()
 	}
 
-	settings.Address = section.Key("address").MustString("")
-	if settings.Address != "" {
-		settings.Enabled = true
+	return nil
+}
+
+func (ts *TracingService) parseSettings() {
+	var section, err = ts.Cfg.Raw.GetSection("tracing.jaeger")
+	if err != nil {
+		return
 	}
 
-	settings.CustomTags = splitTagSettings(section.Key("always_included_tag").MustString(""))
-	settings.SamplerType = section.Key("sampler_type").MustString("")
-	settings.SamplerParam = section.Key("sampler_param").MustFloat64(1)
+	ts.address = section.Key("address").MustString("")
+	if ts.address != "" {
+		ts.enabled = true
+	}
 
-	return settings
+	ts.customTags = splitTagSettings(section.Key("always_included_tag").MustString(""))
+	ts.samplerType = section.Key("sampler_type").MustString("")
+	ts.samplerParam = section.Key("sampler_param").MustFloat64(1)
 }
 
-func internalInit(settings *TracingSettings) (io.Closer, error) {
-	if !settings.Enabled {
-		return &nullCloser{}, nil
-	}
-
+func (ts *TracingService) initGlobalTracer() error {
 	cfg := jaegercfg.Configuration{
-		Disabled: !settings.Enabled,
+		Disabled: !ts.enabled,
 		Sampler: &jaegercfg.SamplerConfig{
-			Type:  settings.SamplerType,
-			Param: settings.SamplerParam,
+			Type:  ts.samplerType,
+			Param: ts.samplerParam,
 		},
 		Reporter: &jaegercfg.ReporterConfig{
 			LogSpans:           false,
-			LocalAgentHostPort: settings.Address,
+			LocalAgentHostPort: ts.address,
 		},
 	}
 
@@ -71,18 +74,31 @@ func internalInit(settings *TracingSettings) (io.Closer, error) {
 	options := []jaegercfg.Option{}
 	options = append(options, jaegercfg.Logger(jLogger))
 
-	for tag, value := range settings.CustomTags {
+	for tag, value := range ts.customTags {
 		options = append(options, jaegercfg.Tag(tag, value))
 	}
 
 	tracer, closer, err := cfg.New("grafana", options...)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	opentracing.InitGlobalTracer(tracer)
-	logger.Info("Initializing Jaeger tracer", "address", settings.Address)
-	return closer, nil
+
+	ts.closer = closer
+
+	return nil
+}
+
+func (ts *TracingService) Run(ctx context.Context) error {
+	<-ctx.Done()
+
+	if ts.closer != nil {
+		ts.log.Info("Closing tracing")
+		ts.closer.Close()
+	}
+
+	return nil
 }
 
 func splitTagSettings(input string) map[string]string {
@@ -110,7 +126,3 @@ func (jlw *jaegerLogWrapper) Error(msg string) {
 func (jlw *jaegerLogWrapper) Infof(msg string, args ...interface{}) {
 	jlw.logger.Info(msg, args)
 }
-
-type nullCloser struct{}
-
-func (*nullCloser) Close() error { return nil }

+ 17 - 2
public/app/containers/Explore/Explore.tsx

@@ -10,6 +10,7 @@ import Graph from './Graph';
 import Table from './Table';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
+import { decodePathComponent } from 'app/core/utils/location_util';
 
 function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
@@ -38,6 +39,19 @@ function makeTimeSeriesList(dataList, options) {
   });
 }
 
+function parseInitialQueries(initial) {
+  if (!initial) {
+    return [];
+  }
+  try {
+    const parsed = JSON.parse(decodePathComponent(initial));
+    return parsed.queries.map(q => q.query);
+  } catch (e) {
+    console.error(e);
+    return [];
+  }
+}
+
 interface IExploreState {
   datasource: any;
   datasourceError: any;
@@ -58,6 +72,7 @@ export class Explore extends React.Component<any, IExploreState> {
 
   constructor(props) {
     super(props);
+    const initialQueries = parseInitialQueries(props.routeParams.initial);
     this.state = {
       datasource: null,
       datasourceError: null,
@@ -65,7 +80,7 @@ export class Explore extends React.Component<any, IExploreState> {
       graphResult: null,
       latency: 0,
       loading: false,
-      queries: ensureQueries(),
+      queries: ensureQueries(initialQueries),
       requestOptions: null,
       showingGraph: true,
       showingTable: true,
@@ -77,7 +92,7 @@ export class Explore extends React.Component<any, IExploreState> {
     const datasource = await this.props.datasourceSrv.get();
     const testResult = await datasource.testDatasource();
     if (testResult.status === 'success') {
-      this.setState({ datasource, datasourceError: null, datasourceLoading: false });
+      this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
     } else {
       this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
     }

+ 15 - 4
public/app/containers/Explore/QueryRows.tsx

@@ -6,13 +6,16 @@ class QueryRow extends PureComponent<any, any> {
   constructor(props) {
     super(props);
     this.state = {
-      query: '',
+      edited: false,
+      query: props.query || '',
     };
   }
 
   handleChangeQuery = value => {
     const { index, onChangeQuery } = this.props;
-    this.setState({ query: value });
+    const { query } = this.state;
+    const edited = query !== value;
+    this.setState({ edited, query: value });
     if (onChangeQuery) {
       onChangeQuery(value, index);
     }
@@ -41,6 +44,7 @@ class QueryRow extends PureComponent<any, any> {
 
   render() {
     const { request } = this.props;
+    const { edited, query } = this.state;
     return (
       <div className="query-row">
         <div className="query-row-tools">
@@ -52,7 +56,12 @@ class QueryRow extends PureComponent<any, any> {
           </button>
         </div>
         <div className="query-field-wrapper">
-          <QueryField onPressEnter={this.handlePressEnter} onQueryChange={this.handleChangeQuery} request={request} />
+          <QueryField
+            initialQuery={edited ? null : query}
+            onPressEnter={this.handlePressEnter}
+            onQueryChange={this.handleChangeQuery}
+            request={request}
+          />
         </div>
       </div>
     );
@@ -63,7 +72,9 @@ export default class QueryRows extends PureComponent<any, any> {
   render() {
     const { className = '', queries, ...handlers } = this.props;
     return (
-      <div className={className}>{queries.map((q, index) => <QueryRow key={q.key} index={index} {...handlers} />)}</div>
+      <div className={className}>
+        {queries.map((q, index) => <QueryRow key={q.key} index={index} query={q.query} {...handlers} />)}
+      </div>
     );
   }
 }

+ 1 - 0
public/app/core/services/analytics.ts

@@ -19,6 +19,7 @@ export class Analytics {
       });
     ga.l = +new Date();
     ga('create', (<any>config).googleAnalyticsId, 'auto');
+    ga('set', 'anonymizeIp', true);
     return ga;
   }
 

+ 13 - 1
public/app/core/services/keybindingSrv.ts

@@ -3,6 +3,7 @@ import _ from 'lodash';
 
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
+import { encodePathComponent } from 'app/core/utils/location_util';
 
 import Mousetrap from 'mousetrap';
 import 'mousetrap-global-bind';
@@ -13,7 +14,7 @@ export class KeybindingSrv {
   timepickerOpen = false;
 
   /** @ngInject */
-  constructor(private $rootScope, private $location) {
+  constructor(private $rootScope, private $location, private datasourceSrv) {
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
@@ -176,6 +177,17 @@ export class KeybindingSrv {
       }
     });
 
+    this.bind('x', async () => {
+      if (dashboard.meta.focusPanelId) {
+        const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
+        const datasource = await this.datasourceSrv.get(panel.datasource);
+        if (datasource && datasource.supportsExplore) {
+          const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
+          this.$location.url(`/explore/${exploreState}`);
+        }
+      }
+    });
+
     // delete panel
     this.bind('p r', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {

+ 79 - 18
public/app/core/specs/file_export.jest.ts

@@ -30,17 +30,17 @@ describe('file_export', () => {
     it('should export points in proper order', () => {
       let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
       const expectedText =
-        'Series;Time;Value\n' +
-        'series_1;1500026100;1\n' +
-        'series_1;1500026200;2\n' +
-        'series_1;1500026300;null\n' +
-        'series_1;1500026400;null\n' +
-        'series_1;1500026500;null\n' +
-        'series_1;1500026600;6\n' +
-        'series_2;1500026100;11\n' +
-        'series_2;1500026200;12\n' +
-        'series_2;1500026300;13\n' +
-        'series_2;1500026500;15\n';
+        '"Series";"Time";"Value"\r\n' +
+        '"series_1";"1500026100";1\r\n' +
+        '"series_1";"1500026200";2\r\n' +
+        '"series_1";"1500026300";null\r\n' +
+        '"series_1";"1500026400";null\r\n' +
+        '"series_1";"1500026500";null\r\n' +
+        '"series_1";"1500026600";6\r\n' +
+        '"series_2";"1500026100";11\r\n' +
+        '"series_2";"1500026200";12\r\n' +
+        '"series_2";"1500026300";13\r\n' +
+        '"series_2";"1500026500";15';
 
       expect(text).toBe(expectedText);
     });
@@ -50,15 +50,76 @@ describe('file_export', () => {
     it('should export points in proper order', () => {
       let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
       const expectedText =
-        'Time;series_1;series_2\n' +
-        '1500026100;1;11\n' +
-        '1500026200;2;12\n' +
-        '1500026300;null;13\n' +
-        '1500026400;null;null\n' +
-        '1500026500;null;15\n' +
-        '1500026600;6;null\n';
+        '"Time";"series_1";"series_2"\r\n' +
+        '"1500026100";1;11\r\n' +
+        '"1500026200";2;12\r\n' +
+        '"1500026300";null;13\r\n' +
+        '"1500026400";null;null\r\n' +
+        '"1500026500";null;15\r\n' +
+        '"1500026600";6;null';
 
       expect(text).toBe(expectedText);
     });
   });
+
+  describe('when exporting table data to csv', () => {
+    it('should properly escape special characters and quote all string values', () => {
+      const inputTable = {
+        columns: [
+          { title: 'integer_value' },
+          { text: 'string_value' },
+          { title: 'float_value' },
+          { text: 'boolean_value' },
+        ],
+        rows: [
+          [123, 'some_string', 1.234, true],
+          [0o765, 'some string with " in the middle', 1e-2, false],
+          [0o765, 'some string with "" in the middle', 1e-2, false],
+          [0o765, 'some string with """ in the middle', 1e-2, false],
+          [0o765, '"some string with " at the beginning', 1e-2, false],
+          [0o765, 'some string with " at the end"', 1e-2, false],
+          [0x123, 'some string with \n in the middle', 10.01, false],
+          [0b1011, 'some string with ; in the middle', -12.34, true],
+          [123, 'some string with ;; in the middle', -12.34, true],
+        ],
+      };
+
+      const returnedText = fileExport.convertTableDataToCsv(inputTable, false);
+
+      const expectedText =
+        '"integer_value";"string_value";"float_value";"boolean_value"\r\n' +
+        '123;"some_string";1.234;true\r\n' +
+        '501;"some string with "" in the middle";0.01;false\r\n' +
+        '501;"some string with """" in the middle";0.01;false\r\n' +
+        '501;"some string with """""" in the middle";0.01;false\r\n' +
+        '501;"""some string with "" at the beginning";0.01;false\r\n' +
+        '501;"some string with "" at the end""";0.01;false\r\n' +
+        '291;"some string with \n in the middle";10.01;false\r\n' +
+        '11;"some string with ; in the middle";-12.34;true\r\n' +
+        '123;"some string with ;; in the middle";-12.34;true';
+
+      expect(returnedText).toBe(expectedText);
+    });
+
+    it('should decode HTML encoded characters', function() {
+      const inputTable = {
+        columns: [{ text: 'string_value' }],
+        rows: [
+          ['&quot;&amp;&auml;'],
+          ['<strong>&quot;some html&quot;</strong>'],
+          ['<a href="http://something/index.html">some text</a>'],
+        ],
+      };
+
+      const returnedText = fileExport.convertTableDataToCsv(inputTable, false);
+
+      const expectedText =
+        '"string_value"\r\n' +
+        '"""&ä"\r\n' +
+        '"<strong>""some html""</strong>"\r\n' +
+        '"<a href=""http://something/index.html"">some text</a>"';
+
+      expect(returnedText).toBe(expectedText);
+    });
+  });
 });

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