Переглянути джерело

Merge branch 'master' into 14701-fix-alert-context

* master: (262 commits)
  pkg/services/dashboards/dashboard_service.go: simplify return
  Updated url query param encoding to exctly match angular encoding
  Updated snapshot
  Added missing props not being passed to scrollbar component, fixes #15058
  Document /api/health
  changelog: adds note for #15062
  change default rotate_token_minutes to 10 minutes
  fix
  load test/ha fixes
  set low login cookie rotate time in ha mode
  fix multiple piechart instances bug
  scripts/build/*: Fix some golint issues
  scripts/build/*: Fix golint issues Url => URL
  build: fixes building grafana completely within docker.
  dont specify domain for auth cookies
  New snapshot reflecting changes
  Makes the clickable side menu header look great in light theme again
  org id fix for load test
  user auth token load tests using k6.io
  moves cookie https setting to [security]
  ...
bergquist 7 роки тому
батько
коміт
0cbc89b063
100 змінених файлів з 9562 додано та 774 видалено
  1. 13 24
      .circleci/config.yml
  2. 20 3
      CHANGELOG.md
  3. 3 1
      Dockerfile
  4. 11 7
      ROADMAP.md
  5. 14 2
      build.go
  6. 20 5
      conf/defaults.ini
  7. 24 5
      conf/sample.ini
  8. 1 1
      devenv/dashboards.yaml
  9. 1674 0
      devenv/dev-dashboards-without-uid/panel_tests_graph.json
  10. 510 0
      devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json
  11. 3342 0
      devenv/dev-dashboards-without-uid/panel_tests_polystat.json
  12. 1250 0
      devenv/dev-dashboards/panel_tests_gauge.json
  13. 2 1
      devenv/docker/ha_test/docker-compose.yaml
  14. 69 0
      devenv/docker/loadtest/README.md
  15. 71 0
      devenv/docker/loadtest/auth_token_test.js
  16. 187 0
      devenv/docker/loadtest/modules/client.js
  17. 35 0
      devenv/docker/loadtest/modules/util.js
  18. 24 0
      devenv/docker/loadtest/run.sh
  19. 1 1
      docs/sources/auth/gitlab.md
  20. 1 1
      docs/sources/features/datasources/cloudwatch.md
  21. 6 1
      docs/sources/features/explore/index.md
  22. 2 2
      docs/sources/http_api/data_source.md
  23. 26 1
      docs/sources/http_api/other.md
  24. 17 0
      docs/sources/installation/configuration.md
  25. 1 0
      docs/sources/reference/templating.md
  26. 2 2
      latest.json
  27. 3 2
      package.json
  28. 1 1
      packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx
  29. 0 0
      packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
  30. 53 5
      packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
  31. 0 4
      packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
  32. 24 0
      packages/grafana-ui/src/components/FormField/FormField.test.tsx
  33. 25 0
      packages/grafana-ui/src/components/FormField/FormField.tsx
  34. 12 0
      packages/grafana-ui/src/components/FormField/_FormField.scss
  35. 19 0
      packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap
  36. 42 0
      packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
  37. 147 0
      packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
  38. 60 76
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  39. 0 23
      packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx
  40. 1 1
      packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
  41. 3 3
      packages/grafana-ui/src/components/Select/Select.tsx
  42. 1 0
      packages/grafana-ui/src/components/Select/_Select.scss
  43. 126 16
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  44. 101 112
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  45. 69 67
      packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
  46. 48 53
      packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
  47. 14 16
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
  48. 29 23
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
  49. 0 0
      packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss
  50. 6 6
      packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
  51. 3 0
      packages/grafana-ui/src/components/index.scss
  52. 6 1
      packages/grafana-ui/src/components/index.ts
  53. 2 0
      packages/grafana-ui/src/index.scss
  54. 89 0
      packages/grafana-ui/src/types/datasource.ts
  55. 0 16
      packages/grafana-ui/src/types/gauge.ts
  56. 2 1
      packages/grafana-ui/src/types/index.ts
  57. 12 0
      packages/grafana-ui/src/types/panel.ts
  58. 118 0
      packages/grafana-ui/src/types/plugin.ts
  59. 3 2
      packages/grafana-ui/src/types/series.ts
  60. 9 8
      packages/grafana-ui/src/utils/processTimeSeries.ts
  61. 81 0
      packages/grafana-ui/src/utils/valueMappings.test.ts
  62. 89 0
      packages/grafana-ui/src/utils/valueMappings.ts
  63. 0 0
      packages/grafana-ui/src/vendor/spectrum.css
  64. 0 0
      packages/grafana-ui/src/vendor/spectrum.js
  65. 6 6
      pkg/api/api.go
  66. 32 10
      pkg/api/common_test.go
  67. 1 1
      pkg/api/dashboard.go
  68. 2 0
      pkg/api/frontendsettings.go
  69. 17 16
      pkg/api/http_server.go
  70. 1 1
      pkg/api/index.go
  71. 59 67
      pkg/api/login.go
  72. 49 16
      pkg/api/login_oauth.go
  73. 2 2
      pkg/api/org_invite.go
  74. 2 2
      pkg/api/signup.go
  75. 5 2
      pkg/components/imguploader/imguploader.go
  76. 2 20
      pkg/log/log.go
  77. 5 1
      pkg/login/ext_user.go
  78. 0 11
      pkg/middleware/auth.go
  79. 19 1
      pkg/middleware/auth_proxy.go
  80. 5 28
      pkg/middleware/middleware.go
  81. 48 30
      pkg/middleware/middleware_test.go
  82. 0 1
      pkg/middleware/org_redirect.go
  83. 11 13
      pkg/middleware/org_redirect_test.go
  84. 5 8
      pkg/middleware/quota_test.go
  85. 3 4
      pkg/middleware/recovery_test.go
  86. 0 21
      pkg/middleware/session.go
  87. 3 3
      pkg/models/context.go
  88. 1 1
      pkg/services/alerting/notifiers/telegram.go
  89. 5 2
      pkg/services/alerting/test_notification.go
  90. 266 0
      pkg/services/auth/auth_token.go
  91. 339 0
      pkg/services/auth/auth_token_test.go
  92. 25 0
      pkg/services/auth/model.go
  93. 38 0
      pkg/services/auth/session_cleanup.go
  94. 36 0
      pkg/services/auth/session_cleanup_test.go
  95. 1 5
      pkg/services/dashboards/dashboard_service.go
  96. 2 1
      pkg/services/provisioning/dashboards/config_reader.go
  97. 2 1
      pkg/services/provisioning/dashboards/file_reader.go
  98. 45 4
      pkg/services/provisioning/dashboards/file_reader_test.go
  99. 0 2
      pkg/services/session/session.go
  100. 1 1
      pkg/services/sqlstore/dashboard_provisioning.go

+ 13 - 24
.circleci/config.yml

@@ -81,20 +81,9 @@ jobs:
     working_directory: /go/src/github.com/grafana/grafana
     steps:
       - checkout
-      - run: 'go get -u github.com/alecthomas/gometalinter'
-      - run: 'go get -u github.com/tsenart/deadcode'
-      - run: 'go get -u github.com/jgautheron/goconst/cmd/goconst'
-      - run: 'go get -u github.com/gordonklaus/ineffassign'
-      - run: 'go get -u honnef.co/go/tools/cmd/megacheck'
-      - run: 'go get -u github.com/opennota/check/cmd/structcheck'
-      - run: 'go get -u github.com/mdempsky/unconvert'
-      - run: 'go get -u github.com/opennota/check/cmd/varcheck'
-      - run:
-          name: run linters
-          command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=gofmt --enable=ineffassign --enable=megacheck --enable=structcheck --enable=unconvert --enable=varcheck ./...'
-      - run:
-          name: run go vet
-          command: 'go vet ./pkg/...'
+      - run:
+          name: Gometalinter tests
+          command: './scripts/gometalinter.sh'
 
   test-frontend:
     docker:
@@ -323,7 +312,7 @@ jobs:
 
   deploy-enterprise-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.1.0
+      - image: grafana/grafana-ci-deploy:1.2.0
     steps:
       - attach_workspace:
           at: .
@@ -346,7 +335,7 @@ jobs:
 
   deploy-enterprise-release:
     docker:
-    - image: grafana/grafana-ci-deploy:1.1.0
+    - image: grafana/grafana-ci-deploy:1.2.0
     steps:
       - attach_workspace:
          at: .
@@ -367,18 +356,18 @@ jobs:
           command: './scripts/build/publish.sh --enterprise'
       - run:
           name: Load GPG private key
-          comand: './scripts/build/load-signing-key.sh'
+          command: './scripts/build/load-signing-key.sh'
       - run:
           name: Update Debian repository
-          command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+          command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
       - run:
           name: Update RPM repository
-          command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+          command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
 
 
   deploy-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.1.0
+      - image: grafana/grafana-ci-deploy:1.2.0
     steps:
       - attach_workspace:
           at: .
@@ -408,7 +397,7 @@ jobs:
 
   deploy-release:
     docker:
-      - image: grafana/grafana-ci-deploy:1.1.0
+      - image: grafana/grafana-ci-deploy:1.2.0
     steps:
       - checkout
       - attach_workspace:
@@ -430,13 +419,13 @@ jobs:
           command: './scripts/build/publish.sh'
       - run:
           name: Load GPG private key
-          comand: './scripts/build/load-signing-key.sh'
+          command: './scripts/build/load-signing-key.sh'
       - run:
           name: Update Debian repository
-          command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+          command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
       - run:
           name: Update RPM repository
-          command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+          command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
 
 workflows:
   version: 2

+ 20 - 3
CHANGELOG.md

@@ -1,4 +1,4 @@
-# 5.5.0 (unreleased)
+# 6.0.0-beta1 (unreleased)
 
 ### New Features
 * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
@@ -12,24 +12,41 @@
 * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
 * **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
 * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
-* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK]req(https://github.com/IntegersOfK)
+* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
 * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
 * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
 * **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
 * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
 * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
 * **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
+* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
 
 ### Bug fixes
 * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
+* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
+
+### Breaking changes
+* **Text Panel**: The text panel does no longer by default allow unsantizied HTML.
+* [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags
+* they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings
+* `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable
+* `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
 
 # 5.4.3 (2019-01-14)
+
+### Tech
+
 * **Docker**: Build and publish docker images for armv7 and arm64 [#14617](https://github.com/grafana/grafana/pull/14617), thx [@johanneswuerbach](https://github.com/johanneswuerbach)
+* **Backend**: Upgrade to golang 1.11.4 [#14580](https://github.com/grafana/grafana/issues/14580)
+* **MySQL** only update session in mysql database when required [#14540](https://github.com/grafana/grafana/pull/14540)
 
 ### Bug fixes
 * **Alerting** Invalid frequency causes division by zero in alert scheduler [#14810](https://github.com/grafana/grafana/issues/14810)
 * **Dashboard** Dashboard links do not update when time range changes [#14493](https://github.com/grafana/grafana/issues/14493)
-
+* **Limits** Support more than 1000 datasources per org [#13883](https://github.com/grafana/grafana/issues/13883)
+* **Backend** fix signed in user for orgId=0 result should return active org id [#14574](https://github.com/grafana/grafana/pull/14574)
+* **Provisioning** Adds orgId to user dto for provisioned dashboards [#14678](https://github.com/grafana/grafana/pull/14678)
 
 # 5.4.2 (2018-12-13)
 

+ 3 - 1
Dockerfile

@@ -19,11 +19,13 @@ COPY package.json package.json
 RUN go run build.go build
 
 # Node build container
-FROM node:8
+FROM node:10.14.2
 
 WORKDIR /usr/src/app/
 
 COPY package.json yarn.lock ./
+COPY packages packages
+
 RUN yarn install --pure-lockfile --no-progress
 
 COPY Gruntfile.js tsconfig.json tslint.json ./

+ 11 - 7
ROADMAP.md

@@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
   
 ### Short term (1-2 months)
   - PRs & Bugs
-  - Multi-Stat panel
+  - React Panel Support
+  - React Query Editor Support
   - Metrics & Log Explore UI 
- 
+  - Grafana UI library shared between grafana & plugins
+  - Seperate visualization from panels
+  - More reuse between Explore & dashboard
+  - Explore logging support for more data sources 
+   
 ### Mid term (2-4 months)  
-  - React Panels 
-  - Change visualization (panel type) on the fly. 
-  - Templating Query Editor UI Plugin hook
-  - Backend plugins
+  - Drilldown links
+  - Dashboards as code workflows 
+  - React migration
+  - New panels 
   
 ### Long term (4 - 8 months)
  - Alerting improvements (silence, per series tracking, etc)
- - Progress on React migration
 
 ### In a distant future far far away
  - Meta queries 

+ 14 - 2
build.go

@@ -46,6 +46,8 @@ var (
 	binaries              []string = []string{"grafana-server", "grafana-cli"}
 	isDev                 bool     = false
 	enterprise            bool     = false
+	skipRpmGen            bool     = false
+	skipDebGen            bool     = false
 )
 
 func main() {
@@ -67,6 +69,8 @@ func main() {
 	flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
 	flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
 	flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
+	flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)")
+	flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)")
 	flag.Parse()
 
 	buildId = shortenBuildId(buildIdRaw)
@@ -165,6 +169,7 @@ func makeLatestDistCopies() {
 		".x86_64.rpm":         "dist/grafana-latest-1.x86_64.rpm",
 		".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
 		".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
+		".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz",
 		".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
 	}
 
@@ -239,6 +244,8 @@ func createDebPackages() {
 	previousPkgArch := pkgArch
 	if pkgArch == "armv7" {
 		pkgArch = "armhf"
+	} else if pkgArch == "armv6" {
+		pkgArch = "armel"
 	}
 	createPackage(linuxPackageOptions{
 		packageType:            "deb",
@@ -289,8 +296,13 @@ func createRpmPackages() {
 }
 
 func createLinuxPackages() {
-	createDebPackages()
-	createRpmPackages()
+	if !skipDebGen {
+		createDebPackages()
+	}
+
+	if !skipRpmGen {
+		createRpmPackages()
+	}
 }
 
 func createPackage(options linuxPackageOptions) {

+ 20 - 5
conf/defaults.ini

@@ -106,6 +106,22 @@ path = grafana.db
 # For "sqlite3" only. cache mode setting used for connecting to the database
 cache_mode = private
 
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+login_remember_days = 7
+
+# How often should the login token be rotated. default to '10m'
+rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+delete_expired_token_after_days = 30
+
 #################################### Session #############################
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -175,11 +191,6 @@ admin_password = admin
 # used for signing
 secret_key = SW2YcwTIb9zpOOhoPsMm
 
-# Auto-login remember days
-login_remember_days = 7
-cookie_username = grafana_user
-cookie_remember_name = grafana_remember
-
 # disable gravatar profile images
 disable_gravatar = false
 
@@ -189,6 +200,9 @@ data_source_proxy_whitelist =
 # disable protection against brute force login attempts
 disable_brute_force_login_protection = false
 
+# set cookies as https only. default is false
+https_flag_cookies = false
+
 #################################### Snapshots ###########################
 [snapshots]
 # snapshot sharing options
@@ -570,6 +584,7 @@ callback_url =
 
 [panels]
 enable_alpha = false
+disable_sanitize_html = false
 
 [enterprise]
 license_path =

+ 24 - 5
conf/sample.ini

@@ -102,6 +102,22 @@ log_queries =
 # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
 ;cache_mode = private
 
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+;cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+;login_remember_days = 7
+
+# How often should the login token be rotated. default to '10'
+;rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+;delete_expired_token_after_days = 30
+
 #################################### Session ####################################
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -162,11 +178,6 @@ log_queries =
 # used for signing
 ;secret_key = SW2YcwTIb9zpOOhoPsMm
 
-# Auto-login remember days
-;login_remember_days = 7
-;cookie_username = grafana_user
-;cookie_remember_name = grafana_remember
-
 # disable gravatar profile images
 ;disable_gravatar = false
 
@@ -176,6 +187,9 @@ log_queries =
 # disable protection against brute force login attempts
 ;disable_brute_force_login_protection = false
 
+# set cookies as https only. default is false
+;https_flag_cookies = false
+
 #################################### Snapshots ###########################
 [snapshots]
 # snapshot sharing options
@@ -495,3 +509,8 @@ log_queries =
 # Path to a valid Grafana Enterprise license.jwt file
 ;license_path =
 
+[panels]
+;enable_alpha = false
+# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
+;disable_sanitize_html = false
+

+ 1 - 1
devenv/dashboards.yaml

@@ -4,6 +4,6 @@ providers:
  - name: 'gdev dashboards'
    folder: 'gdev dashboards'
    type: file
+   updateIntervalSeconds: 15
    options:
      path: devenv/dev-dashboards
-

+ 1674 - 0
devenv/dev-dashboards-without-uid/panel_tests_graph.json

@@ -0,0 +1,1674 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 0
+      },
+      "id": 1,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "no_data_points",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "No Data Points Warning",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 0
+      },
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "datapoints_outside_range",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Datapoints Outside Range Warning",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 0
+      },
+      "id": 3,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Random walk series",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 7
+      },
+      "id": 4,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": "2s",
+      "timeShift": null,
+      "title": "Millisecond res x-axis and tooltip",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Just verify that the tooltip time has millisecond resolution ",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 7
+      },
+      "id": 6,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 9,
+        "w": 16,
+        "x": 0,
+        "y": 14
+      },
+      "id": 5,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "B-series",
+          "yaxis": 2
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "2000,3000,4000,1000,3000,10000",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "2 yaxis and axis labels",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "percent",
+          "label": "Perecent",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": "Pressure",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Verify that axis labels look ok",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 9,
+        "w": 8,
+        "x": 16,
+        "y": 14
+      },
+      "id": 7,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 23
+      },
+      "id": 8,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "null value connected",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 23
+      },
+      "id": 10,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null as zero",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "null value null as zero",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Should be a long line connecting the null region in the `connected`  mode, and in zero it should just be a line with zero value at the null points. ",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 23
+      },
+      "id": 13,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 30
+      },
+      "id": 9,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "B-series",
+          "zindex": -3
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "hide": false,
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Stacking value ontop of nulls",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Stacking values on top of nulls, should treat the null values as zero. ",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 30
+      },
+      "id": 14,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 37
+      },
+      "id": 12,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "B-series",
+          "zindex": -3
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Stacking all series null segment",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Stacking when all values are null should leave a gap in the graph",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 37
+      },
+      "id": 15,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 44
+      },
+      "id": 21,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "C-series",
+          "steppedLine": true
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Null between points",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 44
+      },
+      "id": 22,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 51
+      },
+      "id": 20,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table Single Series Should Take Minimum Height",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 58
+      },
+      "id": 16,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table No Scroll Visible",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 58
+      },
+      "id": 17,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "E",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "F",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "G",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "H",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "I",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "J",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table Should Scroll",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 65
+      },
+      "id": 18,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table No Scroll Visible",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 65
+      },
+      "id": 19,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "E",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "F",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "G",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "H",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "I",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "J",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "K",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "L",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table No Scroll Visible",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    }
+  ],
+  "refresh": false,
+  "revision": 8,
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "gdev",
+    "panel-tests"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "browser",
+  "title": "Panel Tests - Graph",
+  "version": 1
+}

+ 510 - 0
devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json

@@ -0,0 +1,510 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "fill": 2,
+      "gridPos": {
+        "h": 8,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [
+        {
+          "colorMode": "gray",
+          "fill": true,
+          "fillColor": "rgba(255, 255, 255, 0.03)",
+          "from": "08:30",
+          "fromDayOfWeek": 1,
+          "line": false,
+          "lineColor": "rgba(255, 255, 255, 0.2)",
+          "op": "time",
+          "to": "16:45",
+          "toDayOfWeek": 5
+        }
+      ],
+      "timeShift": null,
+      "title": "Business Hours",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "fill": 2,
+      "gridPos": {
+        "h": 8,
+        "w": 24,
+        "x": 0,
+        "y": 8
+      },
+      "id": 4,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [
+        {
+          "colorMode": "red",
+          "fill": true,
+          "fillColor": "rgba(255, 255, 255, 0.03)",
+          "from": "20:00",
+          "fromDayOfWeek": 7,
+          "line": false,
+          "lineColor": "rgba(255, 255, 255, 0.2)",
+          "op": "time",
+          "to": "23:00",
+          "toDayOfWeek": 7
+        }
+      ],
+      "timeShift": null,
+      "title": "Sunday's 20-23",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+        "A-series": "#d683ce"
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "fill": 2,
+      "gridPos": {
+        "h": 8,
+        "w": 24,
+        "x": 0,
+        "y": 16
+      },
+      "id": 3,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 0.5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(255, 0, 0, 0.22)",
+          "from": "",
+          "fromDayOfWeek": 1,
+          "line": true,
+          "lineColor": "rgba(255, 0, 0, 0.32)",
+          "op": "time",
+          "to": "",
+          "toDayOfWeek": 1
+        },
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(255, 127, 0, 0.22)",
+          "fromDayOfWeek": 2,
+          "line": true,
+          "lineColor": "rgba(255, 127, 0, 0.32)",
+          "op": "time",
+          "toDayOfWeek": 2
+        },
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(255, 255, 0, 0.22)",
+          "fromDayOfWeek": 3,
+          "line": true,
+          "lineColor": "rgba(255, 255, 0, 0.22)",
+          "op": "time",
+          "toDayOfWeek": 3
+        },
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(0, 255, 0, 0.22)",
+          "fromDayOfWeek": 4,
+          "line": true,
+          "lineColor": "rgba(0, 255, 0, 0.32)",
+          "op": "time",
+          "toDayOfWeek": 4
+        },
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(0, 0, 255, 0.22)",
+          "fromDayOfWeek": 5,
+          "line": true,
+          "lineColor": "rgba(0, 0, 255, 0.32)",
+          "op": "time",
+          "toDayOfWeek": 5
+        },
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(75, 0, 130, 0.22)",
+          "fromDayOfWeek": 6,
+          "line": true,
+          "lineColor": "rgba(75, 0, 130, 0.32)",
+          "op": "time",
+          "toDayOfWeek": 6
+        },
+        {
+          "colorMode": "custom",
+          "fill": true,
+          "fillColor": "rgba(148, 0, 211, 0.22)",
+          "fromDayOfWeek": 7,
+          "line": true,
+          "lineColor": "rgba(148, 0, 211, 0.32)",
+          "op": "time",
+          "toDayOfWeek": 7
+        }
+      ],
+      "timeShift": null,
+      "title": "Each day of week",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "fill": 2,
+      "gridPos": {
+        "h": 8,
+        "w": 24,
+        "x": 0,
+        "y": 24
+      },
+      "id": 5,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [
+        {
+          "colorMode": "red",
+          "fill": false,
+          "from": "05:00",
+          "line": true,
+          "op": "time"
+        }
+      ],
+      "timeShift": null,
+      "title": "05:00",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    }
+  ],
+  "refresh": false,
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "gdev",
+    "panel-tests"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-30d",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "browser",
+  "title": "Panel Tests - Graph (Time Regions)",
+  "version": 1
+}

+ 3342 - 0
devenv/dev-dashboards-without-uid/panel_tests_polystat.json

@@ -0,0 +1,3342 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "animationModes": [
+        {
+          "text": "Show All",
+          "value": "all"
+        },
+        {
+          "text": "Show Triggered",
+          "value": "triggered"
+        }
+      ],
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "d3DivId": "d3_svg_4",
+      "datasource": "gdev-testdata",
+      "decimals": 2,
+      "displayModes": [
+        {
+          "text": "Show All",
+          "value": "all"
+        },
+        {
+          "text": "Show Triggered",
+          "value": "triggered"
+        }
+      ],
+      "fontSizes": [
+        4,
+        5,
+        6,
+        7,
+        8,
+        9,
+        10,
+        11,
+        12,
+        13,
+        14,
+        15,
+        16,
+        17,
+        18,
+        19,
+        20,
+        22,
+        24,
+        26,
+        28,
+        30,
+        32,
+        34,
+        36,
+        38,
+        40,
+        42,
+        44,
+        46,
+        48,
+        50,
+        52,
+        54,
+        56,
+        58,
+        60,
+        62,
+        64,
+        66,
+        68,
+        70
+      ],
+      "fontTypes": [
+        "Open Sans",
+        "Arial",
+        "Avant Garde",
+        "Bookman",
+        "Consolas",
+        "Courier",
+        "Courier New",
+        "Futura",
+        "Garamond",
+        "Helvetica",
+        "Palatino",
+        "Times",
+        "Times New Roman",
+        "Verdana"
+      ],
+      "format": "none",
+      "gridPos": {
+        "h": 9,
+        "w": 12,
+        "x": 0,
+        "y": 0
+      },
+      "id": 4,
+      "links": [],
+      "notcolors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "operatorName": "avg",
+      "operatorOptions": [
+        {
+          "text": "Average",
+          "value": "avg"
+        },
+        {
+          "text": "Count",
+          "value": "count"
+        },
+        {
+          "text": "Current",
+          "value": "current"
+        },
+        {
+          "text": "Delta",
+          "value": "delta"
+        },
+        {
+          "text": "Difference",
+          "value": "diff"
+        },
+        {
+          "text": "First",
+          "value": "first"
+        },
+        {
+          "text": "Log Min",
+          "value": "logmin"
+        },
+        {
+          "text": "Max",
+          "value": "max"
+        },
+        {
+          "text": "Min",
+          "value": "min"
+        },
+        {
+          "text": "Name",
+          "value": "name"
+        },
+        {
+          "text": "Time of Last Point",
+          "value": "last_time"
+        },
+        {
+          "text": "Time Step",
+          "value": "time_step"
+        },
+        {
+          "text": "Total",
+          "value": "total"
+        }
+      ],
+      "polystat": {
+        "animationSpeed": 2500,
+        "columnAutoSize": true,
+        "columns": "",
+        "defaultClickThrough": "",
+        "defaultClickThroughSanitize": true,
+        "displayLimit": 100,
+        "fontAutoScale": true,
+        "fontSize": 12,
+        "globalDisplayMode": "all",
+        "globalOperatorName": "avg",
+        "gradientEnabled": true,
+        "hexagonSortByDirection": "asc",
+        "hexagonSortByField": "name",
+        "maxMetrics": 0,
+        "polygonBorderColor": "black",
+        "polygonBorderSize": 2,
+        "radius": "",
+        "radiusAutoSize": true,
+        "rowAutoSize": true,
+        "rows": "",
+        "shape": "hexagon_pointed_top",
+        "tooltipDisplayMode": "all",
+        "tooltipDisplayTextTriggeredEmpty": "OK",
+        "tooltipFontSize": 12,
+        "tooltipFontType": "Open Sans",
+        "tooltipPrimarySortDirection": "desc",
+        "tooltipPrimarySortField": "thresholdLevel",
+        "tooltipSecondarySortDirection": "desc",
+        "tooltipSecondarySortField": "value",
+        "tooltipTimestampEnabled": true
+      },
+      "savedComposites": [],
+      "savedOverrides": [],
+      "shapes": [
+        {
+          "text": "Hexagon Pointed Top",
+          "value": "hexagon_pointed_top"
+        },
+        {
+          "text": "Hexagon Flat Top",
+          "value": "hexagon_flat_top"
+        },
+        {
+          "text": "Circle",
+          "value": "circle"
+        },
+        {
+          "text": "Cross",
+          "value": "cross"
+        },
+        {
+          "text": "Diamond",
+          "value": "diamond"
+        },
+        {
+          "text": "Square",
+          "value": "square"
+        },
+        {
+          "text": "Star",
+          "value": "star"
+        },
+        {
+          "text": "Triangle",
+          "value": "triangle"
+        },
+        {
+          "text": "Wye",
+          "value": "wye"
+        }
+      ],
+      "sortDirections": [
+        {
+          "text": "Ascending",
+          "value": "asc"
+        },
+        {
+          "text": "Descending",
+          "value": "desc"
+        }
+      ],
+      "sortFields": [
+        {
+          "text": "Name",
+          "value": "name"
+        },
+        {
+          "text": "Threshold Level",
+          "value": "thresholdLevel"
+        },
+        {
+          "text": "Value",
+          "value": "value"
+        }
+      ],
+      "svgContainer": {},
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "B",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "C",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "D",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "E",
+          "scenarioId": "random_walk"
+        }
+      ],
+      "thresholdStates": [
+        {
+          "text": "ok",
+          "value": 0
+        },
+        {
+          "text": "warning",
+          "value": 1
+        },
+        {
+          "text": "critical",
+          "value": 2
+        },
+        {
+          "text": "custom",
+          "value": 3
+        }
+      ],
+      "title": "Poor use of space",
+      "type": "grafana-polystat-panel",
+      "unitFormats": [
+        {
+          "submenu": [
+            {
+              "text": "none",
+              "value": "none"
+            },
+            {
+              "text": "short",
+              "value": "short"
+            },
+            {
+              "text": "percent (0-100)",
+              "value": "percent"
+            },
+            {
+              "text": "percent (0.0-1.0)",
+              "value": "percentunit"
+            },
+            {
+              "text": "Humidity (%H)",
+              "value": "humidity"
+            },
+            {
+              "text": "decibel",
+              "value": "dB"
+            },
+            {
+              "text": "hexadecimal (0x)",
+              "value": "hex0x"
+            },
+            {
+              "text": "hexadecimal",
+              "value": "hex"
+            },
+            {
+              "text": "scientific notation",
+              "value": "sci"
+            },
+            {
+              "text": "locale format",
+              "value": "locale"
+            }
+          ],
+          "text": "none"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Dollars ($)",
+              "value": "currencyUSD"
+            },
+            {
+              "text": "Pounds (£)",
+              "value": "currencyGBP"
+            },
+            {
+              "text": "Euro (€)",
+              "value": "currencyEUR"
+            },
+            {
+              "text": "Yen (¥)",
+              "value": "currencyJPY"
+            },
+            {
+              "text": "Rubles (₽)",
+              "value": "currencyRUB"
+            },
+            {
+              "text": "Hryvnias (₴)",
+              "value": "currencyUAH"
+            },
+            {
+              "text": "Real (R$)",
+              "value": "currencyBRL"
+            },
+            {
+              "text": "Danish Krone (kr)",
+              "value": "currencyDKK"
+            },
+            {
+              "text": "Icelandic Króna (kr)",
+              "value": "currencyISK"
+            },
+            {
+              "text": "Norwegian Krone (kr)",
+              "value": "currencyNOK"
+            },
+            {
+              "text": "Swedish Krona (kr)",
+              "value": "currencySEK"
+            },
+            {
+              "text": "Czech koruna (czk)",
+              "value": "currencyCZK"
+            },
+            {
+              "text": "Swiss franc (CHF)",
+              "value": "currencyCHF"
+            },
+            {
+              "text": "Polish Złoty (PLN)",
+              "value": "currencyPLN"
+            },
+            {
+              "text": "Bitcoin (฿)",
+              "value": "currencyBTC"
+            }
+          ],
+          "text": "currency"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Hertz (1/s)",
+              "value": "hertz"
+            },
+            {
+              "text": "nanoseconds (ns)",
+              "value": "ns"
+            },
+            {
+              "text": "microseconds (µs)",
+              "value": "µs"
+            },
+            {
+              "text": "milliseconds (ms)",
+              "value": "ms"
+            },
+            {
+              "text": "seconds (s)",
+              "value": "s"
+            },
+            {
+              "text": "minutes (m)",
+              "value": "m"
+            },
+            {
+              "text": "hours (h)",
+              "value": "h"
+            },
+            {
+              "text": "days (d)",
+              "value": "d"
+            },
+            {
+              "text": "duration (ms)",
+              "value": "dtdurationms"
+            },
+            {
+              "text": "duration (s)",
+              "value": "dtdurations"
+            },
+            {
+              "text": "duration (hh:mm:ss)",
+              "value": "dthms"
+            },
+            {
+              "text": "Timeticks (s/100)",
+              "value": "timeticks"
+            }
+          ],
+          "text": "time"
+        },
+        {
+          "submenu": [
+            {
+              "text": "YYYY-MM-DD HH:mm:ss",
+              "value": "dateTimeAsIso"
+            },
+            {
+              "text": "DD/MM/YYYY h:mm:ss a",
+              "value": "dateTimeAsUS"
+            },
+            {
+              "text": "From Now",
+              "value": "dateTimeFromNow"
+            }
+          ],
+          "text": "date & time"
+        },
+        {
+          "submenu": [
+            {
+              "text": "bits",
+              "value": "bits"
+            },
+            {
+              "text": "bytes",
+              "value": "bytes"
+            },
+            {
+              "text": "kibibytes",
+              "value": "kbytes"
+            },
+            {
+              "text": "mebibytes",
+              "value": "mbytes"
+            },
+            {
+              "text": "gibibytes",
+              "value": "gbytes"
+            }
+          ],
+          "text": "data (IEC)"
+        },
+        {
+          "submenu": [
+            {
+              "text": "bits",
+              "value": "decbits"
+            },
+            {
+              "text": "bytes",
+              "value": "decbytes"
+            },
+            {
+              "text": "kilobytes",
+              "value": "deckbytes"
+            },
+            {
+              "text": "megabytes",
+              "value": "decmbytes"
+            },
+            {
+              "text": "gigabytes",
+              "value": "decgbytes"
+            }
+          ],
+          "text": "data (Metric)"
+        },
+        {
+          "submenu": [
+            {
+              "text": "packets/sec",
+              "value": "pps"
+            },
+            {
+              "text": "bits/sec",
+              "value": "bps"
+            },
+            {
+              "text": "bytes/sec",
+              "value": "Bps"
+            },
+            {
+              "text": "kilobits/sec",
+              "value": "Kbits"
+            },
+            {
+              "text": "kilobytes/sec",
+              "value": "KBs"
+            },
+            {
+              "text": "megabits/sec",
+              "value": "Mbits"
+            },
+            {
+              "text": "megabytes/sec",
+              "value": "MBs"
+            },
+            {
+              "text": "gigabytes/sec",
+              "value": "GBs"
+            },
+            {
+              "text": "gigabits/sec",
+              "value": "Gbits"
+            }
+          ],
+          "text": "data rate"
+        },
+        {
+          "submenu": [
+            {
+              "text": "hashes/sec",
+              "value": "Hs"
+            },
+            {
+              "text": "kilohashes/sec",
+              "value": "KHs"
+            },
+            {
+              "text": "megahashes/sec",
+              "value": "MHs"
+            },
+            {
+              "text": "gigahashes/sec",
+              "value": "GHs"
+            },
+            {
+              "text": "terahashes/sec",
+              "value": "THs"
+            },
+            {
+              "text": "petahashes/sec",
+              "value": "PHs"
+            },
+            {
+              "text": "exahashes/sec",
+              "value": "EHs"
+            }
+          ],
+          "text": "hash rate"
+        },
+        {
+          "submenu": [
+            {
+              "text": "ops/sec (ops)",
+              "value": "ops"
+            },
+            {
+              "text": "requests/sec (rps)",
+              "value": "reqps"
+            },
+            {
+              "text": "reads/sec (rps)",
+              "value": "rps"
+            },
+            {
+              "text": "writes/sec (wps)",
+              "value": "wps"
+            },
+            {
+              "text": "I/O ops/sec (iops)",
+              "value": "iops"
+            },
+            {
+              "text": "ops/min (opm)",
+              "value": "opm"
+            },
+            {
+              "text": "reads/min (rpm)",
+              "value": "rpm"
+            },
+            {
+              "text": "writes/min (wpm)",
+              "value": "wpm"
+            }
+          ],
+          "text": "throughput"
+        },
+        {
+          "submenu": [
+            {
+              "text": "millimetre (mm)",
+              "value": "lengthmm"
+            },
+            {
+              "text": "meter (m)",
+              "value": "lengthm"
+            },
+            {
+              "text": "feet (ft)",
+              "value": "lengthft"
+            },
+            {
+              "text": "kilometer (km)",
+              "value": "lengthkm"
+            },
+            {
+              "text": "mile (mi)",
+              "value": "lengthmi"
+            }
+          ],
+          "text": "length"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Square Meters (m²)",
+              "value": "areaM2"
+            },
+            {
+              "text": "Square Feet (ft²)",
+              "value": "areaF2"
+            },
+            {
+              "text": "Square Miles (mi²)",
+              "value": "areaMI2"
+            }
+          ],
+          "text": "area"
+        },
+        {
+          "submenu": [
+            {
+              "text": "milligram (mg)",
+              "value": "massmg"
+            },
+            {
+              "text": "gram (g)",
+              "value": "massg"
+            },
+            {
+              "text": "kilogram (kg)",
+              "value": "masskg"
+            },
+            {
+              "text": "metric ton (t)",
+              "value": "masst"
+            }
+          ],
+          "text": "mass"
+        },
+        {
+          "submenu": [
+            {
+              "text": "metres/second (m/s)",
+              "value": "velocityms"
+            },
+            {
+              "text": "kilometers/hour (km/h)",
+              "value": "velocitykmh"
+            },
+            {
+              "text": "miles/hour (mph)",
+              "value": "velocitymph"
+            },
+            {
+              "text": "knot (kn)",
+              "value": "velocityknot"
+            }
+          ],
+          "text": "velocity"
+        },
+        {
+          "submenu": [
+            {
+              "text": "millilitre (mL)",
+              "value": "mlitre"
+            },
+            {
+              "text": "litre (L)",
+              "value": "litre"
+            },
+            {
+              "text": "cubic metre",
+              "value": "m3"
+            },
+            {
+              "text": "Normal cubic metre",
+              "value": "Nm3"
+            },
+            {
+              "text": "cubic decimetre",
+              "value": "dm3"
+            },
+            {
+              "text": "gallons",
+              "value": "gallons"
+            }
+          ],
+          "text": "volume"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Watt (W)",
+              "value": "watt"
+            },
+            {
+              "text": "Kilowatt (kW)",
+              "value": "kwatt"
+            },
+            {
+              "text": "Milliwatt (mW)",
+              "value": "mwatt"
+            },
+            {
+              "text": "Watt per square metre (W/m²)",
+              "value": "Wm2"
+            },
+            {
+              "text": "Volt-ampere (VA)",
+              "value": "voltamp"
+            },
+            {
+              "text": "Kilovolt-ampere (kVA)",
+              "value": "kvoltamp"
+            },
+            {
+              "text": "Volt-ampere reactive (var)",
+              "value": "voltampreact"
+            },
+            {
+              "text": "Kilovolt-ampere reactive (kvar)",
+              "value": "kvoltampreact"
+            },
+            {
+              "text": "Watt-hour (Wh)",
+              "value": "watth"
+            },
+            {
+              "text": "Kilowatt-hour (kWh)",
+              "value": "kwatth"
+            },
+            {
+              "text": "Kilowatt-min (kWm)",
+              "value": "kwattm"
+            },
+            {
+              "text": "Joule (J)",
+              "value": "joule"
+            },
+            {
+              "text": "Electron volt (eV)",
+              "value": "ev"
+            },
+            {
+              "text": "Ampere (A)",
+              "value": "amp"
+            },
+            {
+              "text": "Kiloampere (kA)",
+              "value": "kamp"
+            },
+            {
+              "text": "Milliampere (mA)",
+              "value": "mamp"
+            },
+            {
+              "text": "Volt (V)",
+              "value": "volt"
+            },
+            {
+              "text": "Kilovolt (kV)",
+              "value": "kvolt"
+            },
+            {
+              "text": "Millivolt (mV)",
+              "value": "mvolt"
+            },
+            {
+              "text": "Decibel-milliwatt (dBm)",
+              "value": "dBm"
+            },
+            {
+              "text": "Ohm (Ω)",
+              "value": "ohm"
+            },
+            {
+              "text": "Lumens (Lm)",
+              "value": "lumens"
+            }
+          ],
+          "text": "energy"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Celsius (°C)",
+              "value": "celsius"
+            },
+            {
+              "text": "Farenheit (°F)",
+              "value": "farenheit"
+            },
+            {
+              "text": "Kelvin (K)",
+              "value": "kelvin"
+            }
+          ],
+          "text": "temperature"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Millibars",
+              "value": "pressurembar"
+            },
+            {
+              "text": "Bars",
+              "value": "pressurebar"
+            },
+            {
+              "text": "Kilobars",
+              "value": "pressurekbar"
+            },
+            {
+              "text": "Hectopascals",
+              "value": "pressurehpa"
+            },
+            {
+              "text": "Kilopascals",
+              "value": "pressurekpa"
+            },
+            {
+              "text": "Inches of mercury",
+              "value": "pressurehg"
+            },
+            {
+              "text": "PSI",
+              "value": "pressurepsi"
+            }
+          ],
+          "text": "pressure"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Newton-meters (Nm)",
+              "value": "forceNm"
+            },
+            {
+              "text": "Kilonewton-meters (kNm)",
+              "value": "forcekNm"
+            },
+            {
+              "text": "Newtons (N)",
+              "value": "forceN"
+            },
+            {
+              "text": "Kilonewtons (kN)",
+              "value": "forcekN"
+            }
+          ],
+          "text": "force"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Gallons/min (gpm)",
+              "value": "flowgpm"
+            },
+            {
+              "text": "Cubic meters/sec (cms)",
+              "value": "flowcms"
+            },
+            {
+              "text": "Cubic feet/sec (cfs)",
+              "value": "flowcfs"
+            },
+            {
+              "text": "Cubic feet/min (cfm)",
+              "value": "flowcfm"
+            },
+            {
+              "text": "Litre/hour",
+              "value": "litreh"
+            },
+            {
+              "text": "Litre/min (l/min)",
+              "value": "flowlpm"
+            },
+            {
+              "text": "milliLitre/min (mL/min)",
+              "value": "flowmlpm"
+            }
+          ],
+          "text": "flow"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Degrees (°)",
+              "value": "degree"
+            },
+            {
+              "text": "Radians",
+              "value": "radian"
+            },
+            {
+              "text": "Gradian",
+              "value": "grad"
+            }
+          ],
+          "text": "angle"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Meters/sec²",
+              "value": "accMS2"
+            },
+            {
+              "text": "Feet/sec²",
+              "value": "accFS2"
+            },
+            {
+              "text": "G unit",
+              "value": "accG"
+            }
+          ],
+          "text": "acceleration"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Becquerel (Bq)",
+              "value": "radbq"
+            },
+            {
+              "text": "curie (Ci)",
+              "value": "radci"
+            },
+            {
+              "text": "Gray (Gy)",
+              "value": "radgy"
+            },
+            {
+              "text": "rad",
+              "value": "radrad"
+            },
+            {
+              "text": "Sievert (Sv)",
+              "value": "radsv"
+            },
+            {
+              "text": "rem",
+              "value": "radrem"
+            },
+            {
+              "text": "Exposure (C/kg)",
+              "value": "radexpckg"
+            },
+            {
+              "text": "roentgen (R)",
+              "value": "radr"
+            },
+            {
+              "text": "Sievert/hour (Sv/h)",
+              "value": "radsvh"
+            }
+          ],
+          "text": "radiation"
+        },
+        {
+          "submenu": [
+            {
+              "text": "parts-per-million (ppm)",
+              "value": "ppm"
+            },
+            {
+              "text": "parts-per-billion (ppb)",
+              "value": "conppb"
+            },
+            {
+              "text": "nanogram per cubic metre (ng/m³)",
+              "value": "conngm3"
+            },
+            {
+              "text": "nanogram per normal cubic metre (ng/Nm³)",
+              "value": "conngNm3"
+            },
+            {
+              "text": "microgram per cubic metre (μg/m³)",
+              "value": "conμgm3"
+            },
+            {
+              "text": "microgram per normal cubic metre (μg/Nm³)",
+              "value": "conμgNm3"
+            },
+            {
+              "text": "milligram per cubic metre (mg/m³)",
+              "value": "conmgm3"
+            },
+            {
+              "text": "milligram per normal cubic metre (mg/Nm³)",
+              "value": "conmgNm3"
+            },
+            {
+              "text": "gram per cubic metre (g/m³)",
+              "value": "congm3"
+            },
+            {
+              "text": "gram per normal cubic metre (g/Nm³)",
+              "value": "congNm3"
+            }
+          ],
+          "text": "concentration"
+        }
+      ]
+    },
+    {
+      "animationModes": [
+        {
+          "text": "Show All",
+          "value": "all"
+        },
+        {
+          "text": "Show Triggered",
+          "value": "triggered"
+        }
+      ],
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "d3DivId": "d3_svg_5",
+      "datasource": "gdev-testdata",
+      "decimals": 2,
+      "displayModes": [
+        {
+          "text": "Show All",
+          "value": "all"
+        },
+        {
+          "text": "Show Triggered",
+          "value": "triggered"
+        }
+      ],
+      "fontSizes": [
+        4,
+        5,
+        6,
+        7,
+        8,
+        9,
+        10,
+        11,
+        12,
+        13,
+        14,
+        15,
+        16,
+        17,
+        18,
+        19,
+        20,
+        22,
+        24,
+        26,
+        28,
+        30,
+        32,
+        34,
+        36,
+        38,
+        40,
+        42,
+        44,
+        46,
+        48,
+        50,
+        52,
+        54,
+        56,
+        58,
+        60,
+        62,
+        64,
+        66,
+        68,
+        70
+      ],
+      "fontTypes": [
+        "Open Sans",
+        "Arial",
+        "Avant Garde",
+        "Bookman",
+        "Consolas",
+        "Courier",
+        "Courier New",
+        "Futura",
+        "Garamond",
+        "Helvetica",
+        "Palatino",
+        "Times",
+        "Times New Roman",
+        "Verdana"
+      ],
+      "format": "none",
+      "gridPos": {
+        "h": 9,
+        "w": 12,
+        "x": 12,
+        "y": 0
+      },
+      "id": 5,
+      "links": [],
+      "notcolors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "operatorName": "avg",
+      "operatorOptions": [
+        {
+          "text": "Average",
+          "value": "avg"
+        },
+        {
+          "text": "Count",
+          "value": "count"
+        },
+        {
+          "text": "Current",
+          "value": "current"
+        },
+        {
+          "text": "Delta",
+          "value": "delta"
+        },
+        {
+          "text": "Difference",
+          "value": "diff"
+        },
+        {
+          "text": "First",
+          "value": "first"
+        },
+        {
+          "text": "Log Min",
+          "value": "logmin"
+        },
+        {
+          "text": "Max",
+          "value": "max"
+        },
+        {
+          "text": "Min",
+          "value": "min"
+        },
+        {
+          "text": "Name",
+          "value": "name"
+        },
+        {
+          "text": "Time of Last Point",
+          "value": "last_time"
+        },
+        {
+          "text": "Time Step",
+          "value": "time_step"
+        },
+        {
+          "text": "Total",
+          "value": "total"
+        }
+      ],
+      "polystat": {
+        "animationSpeed": 2500,
+        "columnAutoSize": true,
+        "columns": "",
+        "defaultClickThrough": "",
+        "defaultClickThroughSanitize": true,
+        "displayLimit": 100,
+        "fontAutoScale": true,
+        "fontSize": 12,
+        "globalDisplayMode": "all",
+        "globalOperatorName": "avg",
+        "gradientEnabled": true,
+        "hexagonSortByDirection": "asc",
+        "hexagonSortByField": "name",
+        "maxMetrics": 0,
+        "polygonBorderColor": "black",
+        "polygonBorderSize": 2,
+        "radius": "",
+        "radiusAutoSize": true,
+        "rowAutoSize": true,
+        "rows": "",
+        "shape": "hexagon_pointed_top",
+        "tooltipDisplayMode": "all",
+        "tooltipDisplayTextTriggeredEmpty": "OK",
+        "tooltipFontSize": 12,
+        "tooltipFontType": "Open Sans",
+        "tooltipPrimarySortDirection": "desc",
+        "tooltipPrimarySortField": "thresholdLevel",
+        "tooltipSecondarySortDirection": "desc",
+        "tooltipSecondarySortField": "value",
+        "tooltipTimestampEnabled": true
+      },
+      "savedComposites": [
+        {
+          "compositeName": "comp",
+          "members": [
+            {
+              "seriesName": "A-series"
+            },
+            {
+              "seriesName": "B-series"
+            }
+          ],
+          "enabled": true,
+          "clickThrough": "",
+          "hideMembers": true,
+          "showName": true,
+          "showValue": true,
+          "animateMode": "all",
+          "thresholdLevel": 0,
+          "sanitizeURLEnabled": true,
+          "sanitizedURL": ""
+        }
+      ],
+      "savedOverrides": [],
+      "shapes": [
+        {
+          "text": "Hexagon Pointed Top",
+          "value": "hexagon_pointed_top"
+        },
+        {
+          "text": "Hexagon Flat Top",
+          "value": "hexagon_flat_top"
+        },
+        {
+          "text": "Circle",
+          "value": "circle"
+        },
+        {
+          "text": "Cross",
+          "value": "cross"
+        },
+        {
+          "text": "Diamond",
+          "value": "diamond"
+        },
+        {
+          "text": "Square",
+          "value": "square"
+        },
+        {
+          "text": "Star",
+          "value": "star"
+        },
+        {
+          "text": "Triangle",
+          "value": "triangle"
+        },
+        {
+          "text": "Wye",
+          "value": "wye"
+        }
+      ],
+      "sortDirections": [
+        {
+          "text": "Ascending",
+          "value": "asc"
+        },
+        {
+          "text": "Descending",
+          "value": "desc"
+        }
+      ],
+      "sortFields": [
+        {
+          "text": "Name",
+          "value": "name"
+        },
+        {
+          "text": "Threshold Level",
+          "value": "thresholdLevel"
+        },
+        {
+          "text": "Value",
+          "value": "value"
+        }
+      ],
+      "svgContainer": {},
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "B",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "C",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "D",
+          "scenarioId": "random_walk"
+        },
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "E",
+          "scenarioId": "random_walk"
+        }
+      ],
+      "thresholdStates": [
+        {
+          "text": "ok",
+          "value": 0
+        },
+        {
+          "text": "warning",
+          "value": 1
+        },
+        {
+          "text": "critical",
+          "value": 2
+        },
+        {
+          "text": "custom",
+          "value": 3
+        }
+      ],
+      "title": "Composite crash",
+      "type": "grafana-polystat-panel",
+      "unitFormats": [
+        {
+          "submenu": [
+            {
+              "text": "none",
+              "value": "none"
+            },
+            {
+              "text": "short",
+              "value": "short"
+            },
+            {
+              "text": "percent (0-100)",
+              "value": "percent"
+            },
+            {
+              "text": "percent (0.0-1.0)",
+              "value": "percentunit"
+            },
+            {
+              "text": "Humidity (%H)",
+              "value": "humidity"
+            },
+            {
+              "text": "decibel",
+              "value": "dB"
+            },
+            {
+              "text": "hexadecimal (0x)",
+              "value": "hex0x"
+            },
+            {
+              "text": "hexadecimal",
+              "value": "hex"
+            },
+            {
+              "text": "scientific notation",
+              "value": "sci"
+            },
+            {
+              "text": "locale format",
+              "value": "locale"
+            }
+          ],
+          "text": "none"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Dollars ($)",
+              "value": "currencyUSD"
+            },
+            {
+              "text": "Pounds (£)",
+              "value": "currencyGBP"
+            },
+            {
+              "text": "Euro (€)",
+              "value": "currencyEUR"
+            },
+            {
+              "text": "Yen (¥)",
+              "value": "currencyJPY"
+            },
+            {
+              "text": "Rubles (₽)",
+              "value": "currencyRUB"
+            },
+            {
+              "text": "Hryvnias (₴)",
+              "value": "currencyUAH"
+            },
+            {
+              "text": "Real (R$)",
+              "value": "currencyBRL"
+            },
+            {
+              "text": "Danish Krone (kr)",
+              "value": "currencyDKK"
+            },
+            {
+              "text": "Icelandic Króna (kr)",
+              "value": "currencyISK"
+            },
+            {
+              "text": "Norwegian Krone (kr)",
+              "value": "currencyNOK"
+            },
+            {
+              "text": "Swedish Krona (kr)",
+              "value": "currencySEK"
+            },
+            {
+              "text": "Czech koruna (czk)",
+              "value": "currencyCZK"
+            },
+            {
+              "text": "Swiss franc (CHF)",
+              "value": "currencyCHF"
+            },
+            {
+              "text": "Polish Złoty (PLN)",
+              "value": "currencyPLN"
+            },
+            {
+              "text": "Bitcoin (฿)",
+              "value": "currencyBTC"
+            }
+          ],
+          "text": "currency"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Hertz (1/s)",
+              "value": "hertz"
+            },
+            {
+              "text": "nanoseconds (ns)",
+              "value": "ns"
+            },
+            {
+              "text": "microseconds (µs)",
+              "value": "µs"
+            },
+            {
+              "text": "milliseconds (ms)",
+              "value": "ms"
+            },
+            {
+              "text": "seconds (s)",
+              "value": "s"
+            },
+            {
+              "text": "minutes (m)",
+              "value": "m"
+            },
+            {
+              "text": "hours (h)",
+              "value": "h"
+            },
+            {
+              "text": "days (d)",
+              "value": "d"
+            },
+            {
+              "text": "duration (ms)",
+              "value": "dtdurationms"
+            },
+            {
+              "text": "duration (s)",
+              "value": "dtdurations"
+            },
+            {
+              "text": "duration (hh:mm:ss)",
+              "value": "dthms"
+            },
+            {
+              "text": "Timeticks (s/100)",
+              "value": "timeticks"
+            }
+          ],
+          "text": "time"
+        },
+        {
+          "submenu": [
+            {
+              "text": "YYYY-MM-DD HH:mm:ss",
+              "value": "dateTimeAsIso"
+            },
+            {
+              "text": "DD/MM/YYYY h:mm:ss a",
+              "value": "dateTimeAsUS"
+            },
+            {
+              "text": "From Now",
+              "value": "dateTimeFromNow"
+            }
+          ],
+          "text": "date & time"
+        },
+        {
+          "submenu": [
+            {
+              "text": "bits",
+              "value": "bits"
+            },
+            {
+              "text": "bytes",
+              "value": "bytes"
+            },
+            {
+              "text": "kibibytes",
+              "value": "kbytes"
+            },
+            {
+              "text": "mebibytes",
+              "value": "mbytes"
+            },
+            {
+              "text": "gibibytes",
+              "value": "gbytes"
+            }
+          ],
+          "text": "data (IEC)"
+        },
+        {
+          "submenu": [
+            {
+              "text": "bits",
+              "value": "decbits"
+            },
+            {
+              "text": "bytes",
+              "value": "decbytes"
+            },
+            {
+              "text": "kilobytes",
+              "value": "deckbytes"
+            },
+            {
+              "text": "megabytes",
+              "value": "decmbytes"
+            },
+            {
+              "text": "gigabytes",
+              "value": "decgbytes"
+            }
+          ],
+          "text": "data (Metric)"
+        },
+        {
+          "submenu": [
+            {
+              "text": "packets/sec",
+              "value": "pps"
+            },
+            {
+              "text": "bits/sec",
+              "value": "bps"
+            },
+            {
+              "text": "bytes/sec",
+              "value": "Bps"
+            },
+            {
+              "text": "kilobits/sec",
+              "value": "Kbits"
+            },
+            {
+              "text": "kilobytes/sec",
+              "value": "KBs"
+            },
+            {
+              "text": "megabits/sec",
+              "value": "Mbits"
+            },
+            {
+              "text": "megabytes/sec",
+              "value": "MBs"
+            },
+            {
+              "text": "gigabytes/sec",
+              "value": "GBs"
+            },
+            {
+              "text": "gigabits/sec",
+              "value": "Gbits"
+            }
+          ],
+          "text": "data rate"
+        },
+        {
+          "submenu": [
+            {
+              "text": "hashes/sec",
+              "value": "Hs"
+            },
+            {
+              "text": "kilohashes/sec",
+              "value": "KHs"
+            },
+            {
+              "text": "megahashes/sec",
+              "value": "MHs"
+            },
+            {
+              "text": "gigahashes/sec",
+              "value": "GHs"
+            },
+            {
+              "text": "terahashes/sec",
+              "value": "THs"
+            },
+            {
+              "text": "petahashes/sec",
+              "value": "PHs"
+            },
+            {
+              "text": "exahashes/sec",
+              "value": "EHs"
+            }
+          ],
+          "text": "hash rate"
+        },
+        {
+          "submenu": [
+            {
+              "text": "ops/sec (ops)",
+              "value": "ops"
+            },
+            {
+              "text": "requests/sec (rps)",
+              "value": "reqps"
+            },
+            {
+              "text": "reads/sec (rps)",
+              "value": "rps"
+            },
+            {
+              "text": "writes/sec (wps)",
+              "value": "wps"
+            },
+            {
+              "text": "I/O ops/sec (iops)",
+              "value": "iops"
+            },
+            {
+              "text": "ops/min (opm)",
+              "value": "opm"
+            },
+            {
+              "text": "reads/min (rpm)",
+              "value": "rpm"
+            },
+            {
+              "text": "writes/min (wpm)",
+              "value": "wpm"
+            }
+          ],
+          "text": "throughput"
+        },
+        {
+          "submenu": [
+            {
+              "text": "millimetre (mm)",
+              "value": "lengthmm"
+            },
+            {
+              "text": "meter (m)",
+              "value": "lengthm"
+            },
+            {
+              "text": "feet (ft)",
+              "value": "lengthft"
+            },
+            {
+              "text": "kilometer (km)",
+              "value": "lengthkm"
+            },
+            {
+              "text": "mile (mi)",
+              "value": "lengthmi"
+            }
+          ],
+          "text": "length"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Square Meters (m²)",
+              "value": "areaM2"
+            },
+            {
+              "text": "Square Feet (ft²)",
+              "value": "areaF2"
+            },
+            {
+              "text": "Square Miles (mi²)",
+              "value": "areaMI2"
+            }
+          ],
+          "text": "area"
+        },
+        {
+          "submenu": [
+            {
+              "text": "milligram (mg)",
+              "value": "massmg"
+            },
+            {
+              "text": "gram (g)",
+              "value": "massg"
+            },
+            {
+              "text": "kilogram (kg)",
+              "value": "masskg"
+            },
+            {
+              "text": "metric ton (t)",
+              "value": "masst"
+            }
+          ],
+          "text": "mass"
+        },
+        {
+          "submenu": [
+            {
+              "text": "metres/second (m/s)",
+              "value": "velocityms"
+            },
+            {
+              "text": "kilometers/hour (km/h)",
+              "value": "velocitykmh"
+            },
+            {
+              "text": "miles/hour (mph)",
+              "value": "velocitymph"
+            },
+            {
+              "text": "knot (kn)",
+              "value": "velocityknot"
+            }
+          ],
+          "text": "velocity"
+        },
+        {
+          "submenu": [
+            {
+              "text": "millilitre (mL)",
+              "value": "mlitre"
+            },
+            {
+              "text": "litre (L)",
+              "value": "litre"
+            },
+            {
+              "text": "cubic metre",
+              "value": "m3"
+            },
+            {
+              "text": "Normal cubic metre",
+              "value": "Nm3"
+            },
+            {
+              "text": "cubic decimetre",
+              "value": "dm3"
+            },
+            {
+              "text": "gallons",
+              "value": "gallons"
+            }
+          ],
+          "text": "volume"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Watt (W)",
+              "value": "watt"
+            },
+            {
+              "text": "Kilowatt (kW)",
+              "value": "kwatt"
+            },
+            {
+              "text": "Milliwatt (mW)",
+              "value": "mwatt"
+            },
+            {
+              "text": "Watt per square metre (W/m²)",
+              "value": "Wm2"
+            },
+            {
+              "text": "Volt-ampere (VA)",
+              "value": "voltamp"
+            },
+            {
+              "text": "Kilovolt-ampere (kVA)",
+              "value": "kvoltamp"
+            },
+            {
+              "text": "Volt-ampere reactive (var)",
+              "value": "voltampreact"
+            },
+            {
+              "text": "Kilovolt-ampere reactive (kvar)",
+              "value": "kvoltampreact"
+            },
+            {
+              "text": "Watt-hour (Wh)",
+              "value": "watth"
+            },
+            {
+              "text": "Kilowatt-hour (kWh)",
+              "value": "kwatth"
+            },
+            {
+              "text": "Kilowatt-min (kWm)",
+              "value": "kwattm"
+            },
+            {
+              "text": "Joule (J)",
+              "value": "joule"
+            },
+            {
+              "text": "Electron volt (eV)",
+              "value": "ev"
+            },
+            {
+              "text": "Ampere (A)",
+              "value": "amp"
+            },
+            {
+              "text": "Kiloampere (kA)",
+              "value": "kamp"
+            },
+            {
+              "text": "Milliampere (mA)",
+              "value": "mamp"
+            },
+            {
+              "text": "Volt (V)",
+              "value": "volt"
+            },
+            {
+              "text": "Kilovolt (kV)",
+              "value": "kvolt"
+            },
+            {
+              "text": "Millivolt (mV)",
+              "value": "mvolt"
+            },
+            {
+              "text": "Decibel-milliwatt (dBm)",
+              "value": "dBm"
+            },
+            {
+              "text": "Ohm (Ω)",
+              "value": "ohm"
+            },
+            {
+              "text": "Lumens (Lm)",
+              "value": "lumens"
+            }
+          ],
+          "text": "energy"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Celsius (°C)",
+              "value": "celsius"
+            },
+            {
+              "text": "Farenheit (°F)",
+              "value": "farenheit"
+            },
+            {
+              "text": "Kelvin (K)",
+              "value": "kelvin"
+            }
+          ],
+          "text": "temperature"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Millibars",
+              "value": "pressurembar"
+            },
+            {
+              "text": "Bars",
+              "value": "pressurebar"
+            },
+            {
+              "text": "Kilobars",
+              "value": "pressurekbar"
+            },
+            {
+              "text": "Hectopascals",
+              "value": "pressurehpa"
+            },
+            {
+              "text": "Kilopascals",
+              "value": "pressurekpa"
+            },
+            {
+              "text": "Inches of mercury",
+              "value": "pressurehg"
+            },
+            {
+              "text": "PSI",
+              "value": "pressurepsi"
+            }
+          ],
+          "text": "pressure"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Newton-meters (Nm)",
+              "value": "forceNm"
+            },
+            {
+              "text": "Kilonewton-meters (kNm)",
+              "value": "forcekNm"
+            },
+            {
+              "text": "Newtons (N)",
+              "value": "forceN"
+            },
+            {
+              "text": "Kilonewtons (kN)",
+              "value": "forcekN"
+            }
+          ],
+          "text": "force"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Gallons/min (gpm)",
+              "value": "flowgpm"
+            },
+            {
+              "text": "Cubic meters/sec (cms)",
+              "value": "flowcms"
+            },
+            {
+              "text": "Cubic feet/sec (cfs)",
+              "value": "flowcfs"
+            },
+            {
+              "text": "Cubic feet/min (cfm)",
+              "value": "flowcfm"
+            },
+            {
+              "text": "Litre/hour",
+              "value": "litreh"
+            },
+            {
+              "text": "Litre/min (l/min)",
+              "value": "flowlpm"
+            },
+            {
+              "text": "milliLitre/min (mL/min)",
+              "value": "flowmlpm"
+            }
+          ],
+          "text": "flow"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Degrees (°)",
+              "value": "degree"
+            },
+            {
+              "text": "Radians",
+              "value": "radian"
+            },
+            {
+              "text": "Gradian",
+              "value": "grad"
+            }
+          ],
+          "text": "angle"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Meters/sec²",
+              "value": "accMS2"
+            },
+            {
+              "text": "Feet/sec²",
+              "value": "accFS2"
+            },
+            {
+              "text": "G unit",
+              "value": "accG"
+            }
+          ],
+          "text": "acceleration"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Becquerel (Bq)",
+              "value": "radbq"
+            },
+            {
+              "text": "curie (Ci)",
+              "value": "radci"
+            },
+            {
+              "text": "Gray (Gy)",
+              "value": "radgy"
+            },
+            {
+              "text": "rad",
+              "value": "radrad"
+            },
+            {
+              "text": "Sievert (Sv)",
+              "value": "radsv"
+            },
+            {
+              "text": "rem",
+              "value": "radrem"
+            },
+            {
+              "text": "Exposure (C/kg)",
+              "value": "radexpckg"
+            },
+            {
+              "text": "roentgen (R)",
+              "value": "radr"
+            },
+            {
+              "text": "Sievert/hour (Sv/h)",
+              "value": "radsvh"
+            }
+          ],
+          "text": "radiation"
+        },
+        {
+          "submenu": [
+            {
+              "text": "parts-per-million (ppm)",
+              "value": "ppm"
+            },
+            {
+              "text": "parts-per-billion (ppb)",
+              "value": "conppb"
+            },
+            {
+              "text": "nanogram per cubic metre (ng/m³)",
+              "value": "conngm3"
+            },
+            {
+              "text": "nanogram per normal cubic metre (ng/Nm³)",
+              "value": "conngNm3"
+            },
+            {
+              "text": "microgram per cubic metre (μg/m³)",
+              "value": "conμgm3"
+            },
+            {
+              "text": "microgram per normal cubic metre (μg/Nm³)",
+              "value": "conμgNm3"
+            },
+            {
+              "text": "milligram per cubic metre (mg/m³)",
+              "value": "conmgm3"
+            },
+            {
+              "text": "milligram per normal cubic metre (mg/Nm³)",
+              "value": "conmgNm3"
+            },
+            {
+              "text": "gram per cubic metre (g/m³)",
+              "value": "congm3"
+            },
+            {
+              "text": "gram per normal cubic metre (g/Nm³)",
+              "value": "congNm3"
+            }
+          ],
+          "text": "concentration"
+        }
+      ]
+    },
+    {
+      "animationModes": [
+        {
+          "text": "Show All",
+          "value": "all"
+        },
+        {
+          "text": "Show Triggered",
+          "value": "triggered"
+        }
+      ],
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "d3DivId": "d3_svg_2",
+      "datasource": "gdev-testdata",
+      "decimals": 2,
+      "displayModes": [
+        {
+          "text": "Show All",
+          "value": "all"
+        },
+        {
+          "text": "Show Triggered",
+          "value": "triggered"
+        }
+      ],
+      "fontSizes": [
+        4,
+        5,
+        6,
+        7,
+        8,
+        9,
+        10,
+        11,
+        12,
+        13,
+        14,
+        15,
+        16,
+        17,
+        18,
+        19,
+        20,
+        22,
+        24,
+        26,
+        28,
+        30,
+        32,
+        34,
+        36,
+        38,
+        40,
+        42,
+        44,
+        46,
+        48,
+        50,
+        52,
+        54,
+        56,
+        58,
+        60,
+        62,
+        64,
+        66,
+        68,
+        70
+      ],
+      "fontTypes": [
+        "Open Sans",
+        "Arial",
+        "Avant Garde",
+        "Bookman",
+        "Consolas",
+        "Courier",
+        "Courier New",
+        "Futura",
+        "Garamond",
+        "Helvetica",
+        "Palatino",
+        "Times",
+        "Times New Roman",
+        "Verdana"
+      ],
+      "format": "none",
+      "gridPos": {
+        "h": 10,
+        "w": 12,
+        "x": 0,
+        "y": 9
+      },
+      "id": 2,
+      "links": [],
+      "notcolors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "operatorName": "avg",
+      "operatorOptions": [
+        {
+          "text": "Average",
+          "value": "avg"
+        },
+        {
+          "text": "Count",
+          "value": "count"
+        },
+        {
+          "text": "Current",
+          "value": "current"
+        },
+        {
+          "text": "Delta",
+          "value": "delta"
+        },
+        {
+          "text": "Difference",
+          "value": "diff"
+        },
+        {
+          "text": "First",
+          "value": "first"
+        },
+        {
+          "text": "Log Min",
+          "value": "logmin"
+        },
+        {
+          "text": "Max",
+          "value": "max"
+        },
+        {
+          "text": "Min",
+          "value": "min"
+        },
+        {
+          "text": "Name",
+          "value": "name"
+        },
+        {
+          "text": "Time of Last Point",
+          "value": "last_time"
+        },
+        {
+          "text": "Time Step",
+          "value": "time_step"
+        },
+        {
+          "text": "Total",
+          "value": "total"
+        }
+      ],
+      "polystat": {
+        "animationSpeed": 2500,
+        "columnAutoSize": true,
+        "columns": 1,
+        "defaultClickThrough": "",
+        "defaultClickThroughSanitize": true,
+        "displayLimit": 100,
+        "fontAutoScale": true,
+        "fontSize": 12,
+        "globalDisplayMode": "all",
+        "globalOperatorName": "avg",
+        "gradientEnabled": true,
+        "hexagonSortByDirection": "asc",
+        "hexagonSortByField": "name",
+        "maxMetrics": 0,
+        "polygonBorderColor": "black",
+        "polygonBorderSize": 2,
+        "radius": "",
+        "radiusAutoSize": true,
+        "rowAutoSize": true,
+        "rows": 1,
+        "shape": "hexagon_pointed_top",
+        "tooltipDisplayMode": "all",
+        "tooltipDisplayTextTriggeredEmpty": "OK",
+        "tooltipFontSize": 12,
+        "tooltipFontType": "Open Sans",
+        "tooltipPrimarySortDirection": "desc",
+        "tooltipPrimarySortField": "thresholdLevel",
+        "tooltipSecondarySortDirection": "desc",
+        "tooltipSecondarySortField": "value",
+        "tooltipTimestampEnabled": true
+      },
+      "savedComposites": [],
+      "savedOverrides": [],
+      "shapes": [
+        {
+          "text": "Hexagon Pointed Top",
+          "value": "hexagon_pointed_top"
+        },
+        {
+          "text": "Hexagon Flat Top",
+          "value": "hexagon_flat_top"
+        },
+        {
+          "text": "Circle",
+          "value": "circle"
+        },
+        {
+          "text": "Cross",
+          "value": "cross"
+        },
+        {
+          "text": "Diamond",
+          "value": "diamond"
+        },
+        {
+          "text": "Square",
+          "value": "square"
+        },
+        {
+          "text": "Star",
+          "value": "star"
+        },
+        {
+          "text": "Triangle",
+          "value": "triangle"
+        },
+        {
+          "text": "Wye",
+          "value": "wye"
+        }
+      ],
+      "sortDirections": [
+        {
+          "text": "Ascending",
+          "value": "asc"
+        },
+        {
+          "text": "Descending",
+          "value": "desc"
+        }
+      ],
+      "sortFields": [
+        {
+          "text": "Name",
+          "value": "name"
+        },
+        {
+          "text": "Threshold Level",
+          "value": "thresholdLevel"
+        },
+        {
+          "text": "Value",
+          "value": "value"
+        }
+      ],
+      "svgContainer": {},
+      "targets": [
+        {
+          "alias": "Sensor-A",
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        },
+        {
+          "alias": "Sensor-B",
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "3433,23432,55"
+        },
+        {
+          "alias": "Sensor-C",
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,2,3,4,5,6"
+        },
+        {
+          "alias": "Sensor-E",
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "thresholdStates": [
+        {
+          "text": "ok",
+          "value": 0
+        },
+        {
+          "text": "warning",
+          "value": 1
+        },
+        {
+          "text": "critical",
+          "value": 2
+        },
+        {
+          "text": "custom",
+          "value": 3
+        }
+      ],
+      "title": "No Value in Sensor-C Bug",
+      "type": "grafana-polystat-panel",
+      "unitFormats": [
+        {
+          "submenu": [
+            {
+              "text": "none",
+              "value": "none"
+            },
+            {
+              "text": "short",
+              "value": "short"
+            },
+            {
+              "text": "percent (0-100)",
+              "value": "percent"
+            },
+            {
+              "text": "percent (0.0-1.0)",
+              "value": "percentunit"
+            },
+            {
+              "text": "Humidity (%H)",
+              "value": "humidity"
+            },
+            {
+              "text": "decibel",
+              "value": "dB"
+            },
+            {
+              "text": "hexadecimal (0x)",
+              "value": "hex0x"
+            },
+            {
+              "text": "hexadecimal",
+              "value": "hex"
+            },
+            {
+              "text": "scientific notation",
+              "value": "sci"
+            },
+            {
+              "text": "locale format",
+              "value": "locale"
+            }
+          ],
+          "text": "none"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Dollars ($)",
+              "value": "currencyUSD"
+            },
+            {
+              "text": "Pounds (£)",
+              "value": "currencyGBP"
+            },
+            {
+              "text": "Euro (€)",
+              "value": "currencyEUR"
+            },
+            {
+              "text": "Yen (¥)",
+              "value": "currencyJPY"
+            },
+            {
+              "text": "Rubles (₽)",
+              "value": "currencyRUB"
+            },
+            {
+              "text": "Hryvnias (₴)",
+              "value": "currencyUAH"
+            },
+            {
+              "text": "Real (R$)",
+              "value": "currencyBRL"
+            },
+            {
+              "text": "Danish Krone (kr)",
+              "value": "currencyDKK"
+            },
+            {
+              "text": "Icelandic Króna (kr)",
+              "value": "currencyISK"
+            },
+            {
+              "text": "Norwegian Krone (kr)",
+              "value": "currencyNOK"
+            },
+            {
+              "text": "Swedish Krona (kr)",
+              "value": "currencySEK"
+            },
+            {
+              "text": "Czech koruna (czk)",
+              "value": "currencyCZK"
+            },
+            {
+              "text": "Swiss franc (CHF)",
+              "value": "currencyCHF"
+            },
+            {
+              "text": "Polish Złoty (PLN)",
+              "value": "currencyPLN"
+            },
+            {
+              "text": "Bitcoin (฿)",
+              "value": "currencyBTC"
+            }
+          ],
+          "text": "currency"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Hertz (1/s)",
+              "value": "hertz"
+            },
+            {
+              "text": "nanoseconds (ns)",
+              "value": "ns"
+            },
+            {
+              "text": "microseconds (µs)",
+              "value": "µs"
+            },
+            {
+              "text": "milliseconds (ms)",
+              "value": "ms"
+            },
+            {
+              "text": "seconds (s)",
+              "value": "s"
+            },
+            {
+              "text": "minutes (m)",
+              "value": "m"
+            },
+            {
+              "text": "hours (h)",
+              "value": "h"
+            },
+            {
+              "text": "days (d)",
+              "value": "d"
+            },
+            {
+              "text": "duration (ms)",
+              "value": "dtdurationms"
+            },
+            {
+              "text": "duration (s)",
+              "value": "dtdurations"
+            },
+            {
+              "text": "duration (hh:mm:ss)",
+              "value": "dthms"
+            },
+            {
+              "text": "Timeticks (s/100)",
+              "value": "timeticks"
+            }
+          ],
+          "text": "time"
+        },
+        {
+          "submenu": [
+            {
+              "text": "YYYY-MM-DD HH:mm:ss",
+              "value": "dateTimeAsIso"
+            },
+            {
+              "text": "DD/MM/YYYY h:mm:ss a",
+              "value": "dateTimeAsUS"
+            },
+            {
+              "text": "From Now",
+              "value": "dateTimeFromNow"
+            }
+          ],
+          "text": "date & time"
+        },
+        {
+          "submenu": [
+            {
+              "text": "bits",
+              "value": "bits"
+            },
+            {
+              "text": "bytes",
+              "value": "bytes"
+            },
+            {
+              "text": "kibibytes",
+              "value": "kbytes"
+            },
+            {
+              "text": "mebibytes",
+              "value": "mbytes"
+            },
+            {
+              "text": "gibibytes",
+              "value": "gbytes"
+            }
+          ],
+          "text": "data (IEC)"
+        },
+        {
+          "submenu": [
+            {
+              "text": "bits",
+              "value": "decbits"
+            },
+            {
+              "text": "bytes",
+              "value": "decbytes"
+            },
+            {
+              "text": "kilobytes",
+              "value": "deckbytes"
+            },
+            {
+              "text": "megabytes",
+              "value": "decmbytes"
+            },
+            {
+              "text": "gigabytes",
+              "value": "decgbytes"
+            }
+          ],
+          "text": "data (Metric)"
+        },
+        {
+          "submenu": [
+            {
+              "text": "packets/sec",
+              "value": "pps"
+            },
+            {
+              "text": "bits/sec",
+              "value": "bps"
+            },
+            {
+              "text": "bytes/sec",
+              "value": "Bps"
+            },
+            {
+              "text": "kilobits/sec",
+              "value": "Kbits"
+            },
+            {
+              "text": "kilobytes/sec",
+              "value": "KBs"
+            },
+            {
+              "text": "megabits/sec",
+              "value": "Mbits"
+            },
+            {
+              "text": "megabytes/sec",
+              "value": "MBs"
+            },
+            {
+              "text": "gigabytes/sec",
+              "value": "GBs"
+            },
+            {
+              "text": "gigabits/sec",
+              "value": "Gbits"
+            }
+          ],
+          "text": "data rate"
+        },
+        {
+          "submenu": [
+            {
+              "text": "hashes/sec",
+              "value": "Hs"
+            },
+            {
+              "text": "kilohashes/sec",
+              "value": "KHs"
+            },
+            {
+              "text": "megahashes/sec",
+              "value": "MHs"
+            },
+            {
+              "text": "gigahashes/sec",
+              "value": "GHs"
+            },
+            {
+              "text": "terahashes/sec",
+              "value": "THs"
+            },
+            {
+              "text": "petahashes/sec",
+              "value": "PHs"
+            },
+            {
+              "text": "exahashes/sec",
+              "value": "EHs"
+            }
+          ],
+          "text": "hash rate"
+        },
+        {
+          "submenu": [
+            {
+              "text": "ops/sec (ops)",
+              "value": "ops"
+            },
+            {
+              "text": "requests/sec (rps)",
+              "value": "reqps"
+            },
+            {
+              "text": "reads/sec (rps)",
+              "value": "rps"
+            },
+            {
+              "text": "writes/sec (wps)",
+              "value": "wps"
+            },
+            {
+              "text": "I/O ops/sec (iops)",
+              "value": "iops"
+            },
+            {
+              "text": "ops/min (opm)",
+              "value": "opm"
+            },
+            {
+              "text": "reads/min (rpm)",
+              "value": "rpm"
+            },
+            {
+              "text": "writes/min (wpm)",
+              "value": "wpm"
+            }
+          ],
+          "text": "throughput"
+        },
+        {
+          "submenu": [
+            {
+              "text": "millimetre (mm)",
+              "value": "lengthmm"
+            },
+            {
+              "text": "meter (m)",
+              "value": "lengthm"
+            },
+            {
+              "text": "feet (ft)",
+              "value": "lengthft"
+            },
+            {
+              "text": "kilometer (km)",
+              "value": "lengthkm"
+            },
+            {
+              "text": "mile (mi)",
+              "value": "lengthmi"
+            }
+          ],
+          "text": "length"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Square Meters (m²)",
+              "value": "areaM2"
+            },
+            {
+              "text": "Square Feet (ft²)",
+              "value": "areaF2"
+            },
+            {
+              "text": "Square Miles (mi²)",
+              "value": "areaMI2"
+            }
+          ],
+          "text": "area"
+        },
+        {
+          "submenu": [
+            {
+              "text": "milligram (mg)",
+              "value": "massmg"
+            },
+            {
+              "text": "gram (g)",
+              "value": "massg"
+            },
+            {
+              "text": "kilogram (kg)",
+              "value": "masskg"
+            },
+            {
+              "text": "metric ton (t)",
+              "value": "masst"
+            }
+          ],
+          "text": "mass"
+        },
+        {
+          "submenu": [
+            {
+              "text": "metres/second (m/s)",
+              "value": "velocityms"
+            },
+            {
+              "text": "kilometers/hour (km/h)",
+              "value": "velocitykmh"
+            },
+            {
+              "text": "miles/hour (mph)",
+              "value": "velocitymph"
+            },
+            {
+              "text": "knot (kn)",
+              "value": "velocityknot"
+            }
+          ],
+          "text": "velocity"
+        },
+        {
+          "submenu": [
+            {
+              "text": "millilitre (mL)",
+              "value": "mlitre"
+            },
+            {
+              "text": "litre (L)",
+              "value": "litre"
+            },
+            {
+              "text": "cubic metre",
+              "value": "m3"
+            },
+            {
+              "text": "Normal cubic metre",
+              "value": "Nm3"
+            },
+            {
+              "text": "cubic decimetre",
+              "value": "dm3"
+            },
+            {
+              "text": "gallons",
+              "value": "gallons"
+            }
+          ],
+          "text": "volume"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Watt (W)",
+              "value": "watt"
+            },
+            {
+              "text": "Kilowatt (kW)",
+              "value": "kwatt"
+            },
+            {
+              "text": "Milliwatt (mW)",
+              "value": "mwatt"
+            },
+            {
+              "text": "Watt per square metre (W/m²)",
+              "value": "Wm2"
+            },
+            {
+              "text": "Volt-ampere (VA)",
+              "value": "voltamp"
+            },
+            {
+              "text": "Kilovolt-ampere (kVA)",
+              "value": "kvoltamp"
+            },
+            {
+              "text": "Volt-ampere reactive (var)",
+              "value": "voltampreact"
+            },
+            {
+              "text": "Kilovolt-ampere reactive (kvar)",
+              "value": "kvoltampreact"
+            },
+            {
+              "text": "Watt-hour (Wh)",
+              "value": "watth"
+            },
+            {
+              "text": "Kilowatt-hour (kWh)",
+              "value": "kwatth"
+            },
+            {
+              "text": "Kilowatt-min (kWm)",
+              "value": "kwattm"
+            },
+            {
+              "text": "Joule (J)",
+              "value": "joule"
+            },
+            {
+              "text": "Electron volt (eV)",
+              "value": "ev"
+            },
+            {
+              "text": "Ampere (A)",
+              "value": "amp"
+            },
+            {
+              "text": "Kiloampere (kA)",
+              "value": "kamp"
+            },
+            {
+              "text": "Milliampere (mA)",
+              "value": "mamp"
+            },
+            {
+              "text": "Volt (V)",
+              "value": "volt"
+            },
+            {
+              "text": "Kilovolt (kV)",
+              "value": "kvolt"
+            },
+            {
+              "text": "Millivolt (mV)",
+              "value": "mvolt"
+            },
+            {
+              "text": "Decibel-milliwatt (dBm)",
+              "value": "dBm"
+            },
+            {
+              "text": "Ohm (Ω)",
+              "value": "ohm"
+            },
+            {
+              "text": "Lumens (Lm)",
+              "value": "lumens"
+            }
+          ],
+          "text": "energy"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Celsius (°C)",
+              "value": "celsius"
+            },
+            {
+              "text": "Farenheit (°F)",
+              "value": "farenheit"
+            },
+            {
+              "text": "Kelvin (K)",
+              "value": "kelvin"
+            }
+          ],
+          "text": "temperature"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Millibars",
+              "value": "pressurembar"
+            },
+            {
+              "text": "Bars",
+              "value": "pressurebar"
+            },
+            {
+              "text": "Kilobars",
+              "value": "pressurekbar"
+            },
+            {
+              "text": "Hectopascals",
+              "value": "pressurehpa"
+            },
+            {
+              "text": "Kilopascals",
+              "value": "pressurekpa"
+            },
+            {
+              "text": "Inches of mercury",
+              "value": "pressurehg"
+            },
+            {
+              "text": "PSI",
+              "value": "pressurepsi"
+            }
+          ],
+          "text": "pressure"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Newton-meters (Nm)",
+              "value": "forceNm"
+            },
+            {
+              "text": "Kilonewton-meters (kNm)",
+              "value": "forcekNm"
+            },
+            {
+              "text": "Newtons (N)",
+              "value": "forceN"
+            },
+            {
+              "text": "Kilonewtons (kN)",
+              "value": "forcekN"
+            }
+          ],
+          "text": "force"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Gallons/min (gpm)",
+              "value": "flowgpm"
+            },
+            {
+              "text": "Cubic meters/sec (cms)",
+              "value": "flowcms"
+            },
+            {
+              "text": "Cubic feet/sec (cfs)",
+              "value": "flowcfs"
+            },
+            {
+              "text": "Cubic feet/min (cfm)",
+              "value": "flowcfm"
+            },
+            {
+              "text": "Litre/hour",
+              "value": "litreh"
+            },
+            {
+              "text": "Litre/min (l/min)",
+              "value": "flowlpm"
+            },
+            {
+              "text": "milliLitre/min (mL/min)",
+              "value": "flowmlpm"
+            }
+          ],
+          "text": "flow"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Degrees (°)",
+              "value": "degree"
+            },
+            {
+              "text": "Radians",
+              "value": "radian"
+            },
+            {
+              "text": "Gradian",
+              "value": "grad"
+            }
+          ],
+          "text": "angle"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Meters/sec²",
+              "value": "accMS2"
+            },
+            {
+              "text": "Feet/sec²",
+              "value": "accFS2"
+            },
+            {
+              "text": "G unit",
+              "value": "accG"
+            }
+          ],
+          "text": "acceleration"
+        },
+        {
+          "submenu": [
+            {
+              "text": "Becquerel (Bq)",
+              "value": "radbq"
+            },
+            {
+              "text": "curie (Ci)",
+              "value": "radci"
+            },
+            {
+              "text": "Gray (Gy)",
+              "value": "radgy"
+            },
+            {
+              "text": "rad",
+              "value": "radrad"
+            },
+            {
+              "text": "Sievert (Sv)",
+              "value": "radsv"
+            },
+            {
+              "text": "rem",
+              "value": "radrem"
+            },
+            {
+              "text": "Exposure (C/kg)",
+              "value": "radexpckg"
+            },
+            {
+              "text": "roentgen (R)",
+              "value": "radr"
+            },
+            {
+              "text": "Sievert/hour (Sv/h)",
+              "value": "radsvh"
+            }
+          ],
+          "text": "radiation"
+        },
+        {
+          "submenu": [
+            {
+              "text": "parts-per-million (ppm)",
+              "value": "ppm"
+            },
+            {
+              "text": "parts-per-billion (ppb)",
+              "value": "conppb"
+            },
+            {
+              "text": "nanogram per cubic metre (ng/m³)",
+              "value": "conngm3"
+            },
+            {
+              "text": "nanogram per normal cubic metre (ng/Nm³)",
+              "value": "conngNm3"
+            },
+            {
+              "text": "microgram per cubic metre (μg/m³)",
+              "value": "conμgm3"
+            },
+            {
+              "text": "microgram per normal cubic metre (μg/Nm³)",
+              "value": "conμgNm3"
+            },
+            {
+              "text": "milligram per cubic metre (mg/m³)",
+              "value": "conmgm3"
+            },
+            {
+              "text": "milligram per normal cubic metre (mg/Nm³)",
+              "value": "conmgNm3"
+            },
+            {
+              "text": "gram per cubic metre (g/m³)",
+              "value": "congm3"
+            },
+            {
+              "text": "gram per normal cubic metre (g/Nm³)",
+              "value": "congNm3"
+            }
+          ],
+          "text": "concentration"
+        }
+      ]
+    }
+  ],
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "panel-test",
+    "gdev"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-6h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "",
+  "title": "Panel Tests - Polystat",
+  "version": 5
+}

+ 1250 - 0
devenv/dev-dashboards/panel_tests_gauge.json

@@ -0,0 +1,1250 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "iteration": 1547810606599,
+  "links": [],
+  "panels": [
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 11,
+      "panels": [],
+      "title": "Value options tests",
+      "type": "row"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 5,
+        "x": 0,
+        "y": 1
+      },
+      "id": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "2",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "avg",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "ms",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Average, 2 decimals, ms unit",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 5,
+        "y": 1
+      },
+      "id": 5,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "max",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "ms",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Max (90 ms), no decimals",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 5,
+        "x": 11,
+        "y": 1
+      },
+      "id": 6,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "p",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "s",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Current (10 ms), no unit, prefix (p), suffix (s)",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 16,
+        "y": 1
+      },
+      "id": 16,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 4,
+        "w": 5,
+        "x": 19,
+        "y": 1
+      },
+      "id": 18,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,91"
+        }
+      ],
+      "timeFrom": "1h",
+      "timeShift": null,
+      "title": "",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 4,
+        "w": 3,
+        "x": 16,
+        "y": 5
+      },
+      "id": 17,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 4,
+        "w": 5,
+        "x": 19,
+        "y": 5
+      },
+      "id": 19,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": []
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,81"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "",
+      "type": "gauge"
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 9
+      },
+      "id": 15,
+      "panels": [],
+      "title": "Value Mappings",
+      "type": "row"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 4,
+        "x": 0,
+        "y": 10
+      },
+      "id": 12,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": [
+          {
+            "from": "",
+            "id": 1,
+            "operator": "",
+            "text": "TEN",
+            "to": "",
+            "type": 1,
+            "value": "10"
+          }
+        ]
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "value mapping 10 -> TEN",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "description": "should read N/A",
+      "gridPos": {
+        "h": 8,
+        "w": 4,
+        "x": 4,
+        "y": 10
+      },
+      "id": 13,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": [
+          {
+            "from": "",
+            "id": 1,
+            "operator": "",
+            "text": "N/A",
+            "to": "",
+            "type": 1,
+            "value": "null"
+          }
+        ]
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,null,null,null,null"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "value mapping null -> N/A",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "description": "should read N/A",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 8,
+        "y": 10
+      },
+      "id": 20,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": [
+          {
+            "from": "0",
+            "id": 1,
+            "operator": "",
+            "text": "OK",
+            "to": "10",
+            "type": 2,
+            "value": "null"
+          }
+        ]
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,null,null,null,null,10"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "value mapping range, 0-10 -> OK, value 10",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "description": "should read N/A",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 14,
+        "y": 10
+      },
+      "id": 21,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "current",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "none",
+        "valueMappings": [
+          {
+            "from": "0",
+            "id": 1,
+            "operator": "",
+            "text": "OK",
+            "to": "90",
+            "type": 2,
+            "value": "null"
+          },
+          {
+            "from": "90",
+            "id": 2,
+            "operator": "",
+            "text": "BAD",
+            "to": "100",
+            "type": 2,
+            "value": ""
+          }
+        ]
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,null,null,null,null,10,95"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "value mapping range, 90-100 -> BAD, value 90",
+      "type": "gauge"
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 18
+      },
+      "id": 9,
+      "panels": [],
+      "title": "Templating & Repeat",
+      "type": "row"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 0,
+        "y": 19
+      },
+      "id": 7,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "2",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "$Servers",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "avg",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "ms",
+        "valueMappings": []
+      },
+      "repeat": "Servers",
+      "repeatDirection": "h",
+      "scopedVars": {
+        "Servers": {
+          "selected": false,
+          "text": "server1",
+          "value": "server1"
+        }
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "repeat $Servers",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 6,
+        "y": 19
+      },
+      "id": 22,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "2",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "$Servers",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "avg",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "ms",
+        "valueMappings": []
+      },
+      "repeat": null,
+      "repeatDirection": "h",
+      "repeatIteration": 1547810606599,
+      "repeatPanelId": 7,
+      "scopedVars": {
+        "Servers": {
+          "selected": false,
+          "text": "server2",
+          "value": "server2"
+        }
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "repeat $Servers",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 12,
+        "y": 19
+      },
+      "id": 23,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "2",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "$Servers",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "avg",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "ms",
+        "valueMappings": []
+      },
+      "repeat": null,
+      "repeatDirection": "h",
+      "repeatIteration": 1547810606599,
+      "repeatPanelId": 7,
+      "scopedVars": {
+        "Servers": {
+          "selected": false,
+          "text": "server3",
+          "value": "server3"
+        }
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "repeat $Servers",
+      "type": "gauge"
+    },
+    {
+      "datasource": "gdev-testdata",
+      "gridPos": {
+        "h": 8,
+        "w": 6,
+        "x": 18,
+        "y": 19
+      },
+      "id": 24,
+      "links": [],
+      "nullPointMode": "null",
+      "options-gauge": {
+        "baseColor": "#299c46",
+        "decimals": "2",
+        "maxValue": 100,
+        "minValue": 0,
+        "options": {
+          "baseColor": "#299c46",
+          "decimals": 0,
+          "maxValue": 100,
+          "minValue": 0,
+          "prefix": "",
+          "showThresholdLabels": false,
+          "showThresholdMarkers": true,
+          "stat": "avg",
+          "suffix": "",
+          "thresholds": [],
+          "unit": "none",
+          "valueMappings": []
+        },
+        "prefix": "$Servers",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true,
+        "stat": "avg",
+        "suffix": "",
+        "thresholds": [
+          {
+            "color": "#e24d42",
+            "index": 2,
+            "value": 90
+          },
+          {
+            "color": "#ef843c",
+            "index": 1,
+            "value": 75
+          },
+          {
+            "color": "#7EB26D",
+            "index": 0,
+            "value": null
+          }
+        ],
+        "unit": "ms",
+        "valueMappings": []
+      },
+      "repeat": null,
+      "repeatDirection": "h",
+      "repeatIteration": 1547810606599,
+      "repeatPanelId": 7,
+      "scopedVars": {
+        "Servers": {
+          "selected": false,
+          "text": "server4",
+          "value": "server4"
+        }
+      },
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "repeat $Servers",
+      "type": "gauge"
+    }
+  ],
+  "refresh": false,
+  "schemaVersion": 17,
+  "style": "dark",
+  "tags": [
+    "gdev",
+    "panel-tests"
+  ],
+  "templating": {
+    "list": [
+      {
+        "allValue": null,
+        "current": {
+          "selected": true,
+          "tags": [],
+          "text": "All",
+          "value": [
+            "$__all"
+          ]
+        },
+        "hide": 0,
+        "includeAll": true,
+        "label": null,
+        "multi": true,
+        "name": "Servers",
+        "options": [
+          {
+            "selected": true,
+            "text": "All",
+            "value": "$__all"
+          },
+          {
+            "selected": false,
+            "text": "server1",
+            "value": "server1"
+          },
+          {
+            "selected": false,
+            "text": "server2",
+            "value": "server2"
+          },
+          {
+            "selected": false,
+            "text": "server3",
+            "value": "server3"
+          },
+          {
+            "selected": false,
+            "text": "server4",
+            "value": "server4"
+          }
+        ],
+        "query": "server1,server2,server3,server4",
+        "skipUrlSync": false,
+        "type": "custom"
+      }
+    ]
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "",
+  "title": "Panel Tests - Gauge",
+  "uid": "_5rDmaQiz",
+  "version": 5
+}

+ 2 - 1
devenv/docker/ha_test/docker-compose.yaml

@@ -54,7 +54,8 @@ services:
       # - GF_DATABASE_SSL_MODE=disable
       # - GF_SESSION_PROVIDER=postgres
       # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
-      - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
+      - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
+      - GF_LOGIN_ROTATE_TOKEN_MINUTES=2
     ports:
       - 3000
     depends_on:

+ 69 - 0
devenv/docker/loadtest/README.md

@@ -0,0 +1,69 @@
+# Grafana load test
+
+Runs load tests and checks using [k6](https://k6.io/).
+
+## Prerequisites
+
+Docker
+
+## Run
+
+Run load test for 15 minutes:
+
+```bash
+$ ./run.sh
+```
+
+Run load test for custom duration:
+
+```bash
+$ ./run.sh -d 10s
+```
+
+Example output:
+
+```bash
+
+          /\      |‾‾|  /‾‾/  /‾/
+     /\  /  \     |  |_/  /  / /
+    /  \/    \    |      |  /  ‾‾\
+   /          \   |  |‾\  \ | (_) |
+  / __________ \  |__|  \__\ \___/ .io
+
+  execution: local
+     output: -
+     script: src/auth_token_test.js
+
+    duration: 15m0s, iterations: -
+         vus: 2,     max: 2
+
+    done [==========================================================] 15m0s / 15m0s
+
+    █ user auth token test
+
+      █ user authenticates thru ui with username and password
+
+        ✓ response status is 200
+        ✓ response has cookie 'grafana_session' with 32 characters
+
+      █ batch tsdb requests
+
+        ✓ response status is 200
+
+    checks.....................: 100.00% ✓ 32844 ✗ 0
+    data_received..............: 411 MB  457 kB/s
+    data_sent..................: 12 MB   14 kB/s
+    group_duration.............: avg=95.64ms  min=16.42ms  med=94.35ms  max=307.52ms p(90)=137.78ms p(95)=146.75ms
+    http_req_blocked...........: avg=1.27ms   min=942ns    med=610.08µs max=48.32ms  p(90)=2.92ms   p(95)=4.25ms
+    http_req_connecting........: avg=1.06ms   min=0s       med=456.79µs max=47.19ms  p(90)=2.55ms   p(95)=3.78ms
+    http_req_duration..........: avg=58.16ms  min=1ms      med=52.59ms  max=293.35ms p(90)=109.53ms p(95)=120.19ms
+    http_req_receiving.........: avg=38.98µs  min=6.43µs   med=32.55µs  max=16.2ms   p(90)=64.63µs  p(95)=78.8µs
+    http_req_sending...........: avg=328.66µs min=8.09µs   med=110.77µs max=44.13ms  p(90)=552.65µs p(95)=1.09ms
+    http_req_tls_handshaking...: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s
+    http_req_waiting...........: avg=57.79ms  min=935.02µs med=52.15ms  max=293.06ms p(90)=109.04ms p(95)=119.71ms
+    http_reqs..................: 34486   38.317775/s
+    iteration_duration.........: avg=1.09s    min=1.81µs   med=1.09s    max=1.3s     p(90)=1.13s    p(95)=1.14s
+    iterations.................: 1642    1.824444/s
+    vus........................: 2       min=2   max=2
+    vus_max....................: 2       min=2   max=2
+```

+ 71 - 0
devenv/docker/loadtest/auth_token_test.js

@@ -0,0 +1,71 @@
+import { sleep, check, group } from 'k6';
+import { createClient, createBasicAuthClient } from './modules/client.js';
+import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js';
+
+export let options = {
+  noCookiesReset: true
+};
+
+let endpoint = __ENV.URL || 'http://localhost:3000';
+const client = createClient(endpoint);
+
+export const setup = () => {
+  const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin');
+  const orgId = createTestOrgIfNotExists(basicAuthClient);
+  const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient);
+  client.withOrgId(orgId);
+  return {
+    orgId: orgId,
+    datasourceId: datasourceId,
+  };
+}
+
+export default (data) => {
+  group("user auth token test", () => {
+    if (__ITER === 0) {
+      group("user authenticates thru ui with username and password", () => {
+        let res = client.ui.login('admin', 'admin');
+
+        check(res, {
+          'response status is 200': (r) => r.status === 200,
+          'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32,
+        });
+      });
+    }
+
+    if (__ITER !== 0) {
+      group("batch tsdb requests", () => {
+        const batchCount = 20;
+        const requests = [];
+        const payload = {
+          from: '1547765247624',
+          to: '1547768847624',
+          queries: [{
+            refId: 'A',
+            scenarioId: 'random_walk',
+            intervalMs: 10000,
+            maxDataPoints: 433,
+            datasourceId: data.datasourceId,
+          }]
+        };
+
+        requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' });
+
+        for (let n = 0; n < batchCount; n++) {
+          requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload });
+        }
+
+        let responses = client.batch(requests);
+        for (let n = 0; n < batchCount; n++) {
+          check(responses[n], {
+            'response status is 200': (r) => r.status === 200,
+          });
+        }
+      });
+    }
+  });
+
+  sleep(1)
+}
+
+export const teardown = (data) => {}

+ 187 - 0
devenv/docker/loadtest/modules/client.js

@@ -0,0 +1,187 @@
+import http from "k6/http";
+import encoding from 'k6/encoding';
+
+export const UIEndpoint = class UIEndpoint {
+  constructor(httpClient) {
+    this.httpClient = httpClient;
+  }
+
+  login(username, pwd) {
+    const payload = { user: username, password: pwd };
+    return this.httpClient.formPost('/login', payload);
+  }
+}
+
+export const DatasourcesEndpoint = class DatasourcesEndpoint {
+  constructor(httpClient) {
+    this.httpClient = httpClient;
+  }
+
+  getById(id) {
+    return this.httpClient.get(`/datasources/${id}`);
+  }
+
+  getByName(name) {
+    return this.httpClient.get(`/datasources/name/${name}`);
+  }
+
+  create(payload) {
+    return this.httpClient.post(`/datasources`, JSON.stringify(payload));
+  }
+
+  delete(id) {
+    return this.httpClient.delete(`/datasources/${id}`);
+  }
+}
+
+export const OrganizationsEndpoint = class OrganizationsEndpoint {
+  constructor(httpClient) {
+    this.httpClient = httpClient;
+  }
+
+  getById(id) {
+    return this.httpClient.get(`/orgs/${id}`);
+  }
+
+  getByName(name) {
+    return this.httpClient.get(`/orgs/name/${name}`);
+  }
+
+  create(name) {
+    let payload = {
+      name: name,
+    };
+    return this.httpClient.post(`/orgs`, JSON.stringify(payload));
+  }
+
+  delete(id) {
+    return this.httpClient.delete(`/orgs/${id}`);
+  }
+}
+
+export const GrafanaClient = class GrafanaClient {
+  constructor(httpClient) {
+    httpClient.onBeforeRequest = this.onBeforeRequest;
+    this.raw = httpClient;
+    this.ui = new UIEndpoint(httpClient);
+    this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api'));
+    this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api'));
+  }
+
+  batch(requests) {
+    return this.raw.batch(requests);
+  }
+
+  withOrgId(orgId) {
+    this.orgId = orgId;
+  }
+
+  onBeforeRequest(params) {
+    if (this.orgId && this.orgId > 0) {
+      params = params.headers || {};
+      params.headers["X-Grafana-Org-Id"] = this.orgId;
+    }
+  }
+}
+
+export const BaseClient = class BaseClient {
+  constructor(url, subUrl) {
+    if (url.endsWith('/')) {
+      url = url.substring(0, url.length - 1);
+    }
+
+    if (subUrl.endsWith('/')) {
+      subUrl = subUrl.substring(0, subUrl.length - 1);
+    }
+
+    this.url = url + subUrl;
+    this.onBeforeRequest = () => {};
+  }
+
+  withUrl(subUrl) {
+    let c = new BaseClient(this.url,  subUrl);
+    c.onBeforeRequest = this.onBeforeRequest;
+    return c;
+  }
+
+  beforeRequest(params) {
+
+  }
+
+  get(url, params) {
+    params = params || {};
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.get(this.url + url, params);
+  }
+
+  formPost(url, body, params) {
+    params = params || {};
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.post(this.url + url, body, params);
+  }
+
+  post(url, body, params) {
+    params = params || {};
+    params.headers = params.headers || {};
+    params.headers['Content-Type'] = 'application/json';
+
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.post(this.url + url, body, params);
+  }
+
+  delete(url, params) {
+    params = params || {};
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.del(this.url + url, null, params);
+  }
+
+  batch(requests) {
+    for (let n = 0; n < requests.length; n++) {
+      let params = requests[n].params || {};
+      params.headers = params.headers || {};
+      params.headers['Content-Type'] = 'application/json';
+      this.beforeRequest(params);
+      this.onBeforeRequest(params);
+      requests[n].params = params;
+      requests[n].url = this.url + requests[n].url;
+      if (requests[n].body) {
+        requests[n].body = JSON.stringify(requests[n].body);
+      }
+    }
+
+    return http.batch(requests);
+  }
+}
+
+export class BasicAuthClient extends BaseClient {
+  constructor(url, subUrl, username, password) {
+    super(url, subUrl);
+    this.username = username;
+    this.password = password;
+  }
+
+  withUrl(subUrl) {
+    let c = new BasicAuthClient(this.url,  subUrl, this.username, this.password);
+    c.onBeforeRequest = this.onBeforeRequest;
+    return c;
+  }
+
+  beforeRequest(params) {
+    params = params || {};
+    params.headers = params.headers || {};
+    let token = `${this.username}:${this.password}`;
+    params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`;
+  }
+}
+
+export const createClient = (url) => {
+  return new GrafanaClient(new BaseClient(url, ''));
+}
+
+export const createBasicAuthClient = (url, username, password) => {
+  return new GrafanaClient(new BasicAuthClient(url, '', username, password));
+}

+ 35 - 0
devenv/docker/loadtest/modules/util.js

@@ -0,0 +1,35 @@
+export const createTestOrgIfNotExists = (client) => {
+  let orgId = 0;
+  let res = client.orgs.getByName('k6');
+  if (res.status === 404) {
+    res = client.orgs.create('k6');
+    if (res.status !== 200) {
+      throw new Error('Expected 200 response status when creating org');
+    }
+    orgId = res.json().orgId;
+  } else {
+    orgId = res.json().id;
+  }
+
+  client.withOrgId(orgId);
+  return orgId;
+}
+
+export const createTestdataDatasourceIfNotExists = (client) => {
+  const payload = {
+    access: 'proxy',
+    isDefault: false,
+    name: 'k6-testdata',
+    type: 'testdata',
+  };
+
+  let res = client.datasources.getByName(payload.name);
+  if (res.status === 404) {
+    res = client.datasources.create(payload);
+    if (res.status !== 200) {
+      throw new Error('Expected 200 response status when creating datasource');
+    }
+  }
+
+  return res.json().id;
+}

+ 24 - 0
devenv/docker/loadtest/run.sh

@@ -0,0 +1,24 @@
+#/bin/bash
+
+PWD=$(pwd)
+
+run() {
+  duration='15m'
+  url='http://localhost:3000'
+
+  while getopts ":d:u:" o; do
+    case "${o}" in
+				d)
+            duration=${OPTARG}
+            ;;
+        u)
+            url=${OPTARG}
+            ;;
+    esac
+	done
+	shift $((OPTIND-1))
+
+  docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js
+}
+
+run "$@"

+ 1 - 1
docs/sources/auth/gitlab.md

@@ -47,7 +47,7 @@ authentication:
 
 ```bash
 [auth.gitlab]
-enabled = false
+enabled = true
 allow_sign_up = false
 client_id = GITLAB_APPLICATION_ID
 client_secret = GITLAB_SECRET

+ 1 - 1
docs/sources/features/datasources/cloudwatch.md

@@ -38,7 +38,7 @@ Name | Description
 
 ### IAM Roles
 
-Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If you grafana
+Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If your Grafana
 server is running on AWS you can use IAM Roles and authentication will be handled automatically.
 
 Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)

+ 6 - 1
docs/sources/features/explore/index.md

@@ -1,5 +1,6 @@
 +++
 title = "Explore"
+keywords = ["explore", "loki", "logs"]
 type = "docs"
 [menu.docs]
 name = "Explore"
@@ -8,7 +9,11 @@ parent = "features"
 weight = 5
 +++
 
-# Introduction
+# Explore
+
+> Explore is only available in Grafana 6.0 and above.
+
+## Introduction
 
 One of the major new features of Grafana 6.0 is the new query-focused Explore workflow for troubleshooting and/or for data exploration.
 

+ 2 - 2
docs/sources/http_api/data_source.md

@@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
     "defaultRegion": "us-west-1"
   },
   "secureJsonData": {
-    "accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded
-    "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded
+    "accessKey": "Ol4pIDpeKSA6XikgOl4p",
+    "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
   }
 }
 ```

+ 26 - 1
docs/sources/http_api/other.md

@@ -82,4 +82,29 @@ HTTP/1.1 200
 Content-Type: application/json
 
 {"message": "Logged in"}
-```
+```
+
+# Health API
+
+## Returns health information about Grafana
+
+`GET /api/health`
+
+**Example Request**
+
+```http
+GET /api/health
+Accept: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200 OK
+
+{
+  "commit": "087143285",
+  "database": "ok",
+  "version": "5.1.3"
+}
+```

+ 17 - 0
docs/sources/installation/configuration.md

@@ -391,6 +391,12 @@ value is `true`.
 If you want to track Grafana usage via Google analytics specify *your* Universal
 Analytics ID here. By default this feature is disabled.
 
+### check_for_updates
+
+Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
+in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
+send any sensitive information.
+
 <hr />
 
 ## [dashboards]
@@ -589,3 +595,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler
 Alert notifications can include images, but rendering many images at the same time can overload the server.
 This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
 value is `5`.
+
+## [panels]
+
+### enable_alpha
+Set to true if you want to test panels that are not yet ready for general usage.
+
+### disable_sanitize_html
+If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default
+is false. This settings was introduced in Grafana v6.0.
+
+

+ 1 - 0
docs/sources/reference/templating.md

@@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description
 `csv`| ${servers:csv} |  `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
 `distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
 `lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
+`percentencode` | ${servers:percentencode} |  `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
 
 Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).
 

+ 2 - 2
latest.json

@@ -1,4 +1,4 @@
 {
-  "stable": "5.4.2",
-  "testing": "5.4.2"
+  "stable": "5.4.3",
+  "testing": "5.4.3"
 }

+ 3 - 2
package.json

@@ -5,7 +5,7 @@
     "company": "Grafana Labs"
   },
   "name": "grafana",
-  "version": "5.5.0-pre1",
+  "version": "6.0.0-pre1",
   "repository": {
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"
@@ -188,7 +188,8 @@
     "slate-react": "^0.12.4",
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
-    "tinycolor2": "^1.4.1"
+    "tinycolor2": "^1.4.1",
+    "xss": "^1.0.3"
   },
   "resolutions": {
     "caniuse-db": "1.0.30000772",

+ 1 - 1
packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import _ from 'lodash';
 import $ from 'jquery';
-import 'vendor/spectrum';
+import '../../vendor/spectrum';
 
 export interface Props {
   color: string;

+ 0 - 0
public/sass/components/_color_picker.scss → packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss


+ 53 - 5
packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx

@@ -1,4 +1,5 @@
 import React, { PureComponent } from 'react';
+import _ from 'lodash';
 import Scrollbars from 'react-custom-scrollbars';
 
 interface Props {
@@ -6,7 +7,11 @@ interface Props {
   autoHide?: boolean;
   autoHideTimeout?: number;
   autoHideDuration?: number;
+  autoHeightMax?: string;
   hideTracksWhenNotNeeded?: boolean;
+  scrollTop?: number;
+  setScrollTop: (event: any) => void;
+  autoHeightMin?: number | string;
 }
 
 /**
@@ -15,29 +20,72 @@ interface Props {
 export class CustomScrollbar extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
     customClassName: 'custom-scrollbars',
-    autoHide: true,
+    autoHide: false,
     autoHideTimeout: 200,
     autoHideDuration: 200,
+    setScrollTop: () => {},
     hideTracksWhenNotNeeded: false,
+    autoHeightMin: '0',
+    autoHeightMax: '100%',
   };
 
+  private ref: React.RefObject<Scrollbars>;
+
+  constructor(props: Props) {
+    super(props);
+    this.ref = React.createRef<Scrollbars>();
+  }
+
+  updateScroll() {
+    const ref = this.ref.current;
+
+    if (ref && !_.isNil(this.props.scrollTop)) {
+      if (this.props.scrollTop > 10000) {
+        ref.scrollToBottom();
+      } else {
+        ref.scrollTop(this.props.scrollTop);
+      }
+    }
+  }
+
+  componentDidMount() {
+    this.updateScroll();
+  }
+
+  componentDidUpdate() {
+    this.updateScroll();
+  }
+
   render() {
-    const { customClassName, children, ...scrollProps } = this.props;
+    const {
+      customClassName,
+      children,
+      autoHeightMax,
+      autoHeightMin,
+      setScrollTop,
+      autoHide,
+      autoHideTimeout,
+      hideTracksWhenNotNeeded,
+    } = this.props;
 
     return (
       <Scrollbars
+        ref={this.ref}
         className={customClassName}
+        onScroll={setScrollTop}
         autoHeight={true}
+        autoHide={autoHide}
+        autoHideTimeout={autoHideTimeout}
+        hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
         // Before these where set to inhert but that caused problems with cut of legends in firefox
-        autoHeightMin={'0'}
-        autoHeightMax={'100%'}
+        autoHeightMax={autoHeightMax}
+        autoHeightMin={autoHeightMin}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
         renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
         renderView={props => <div {...props} className="view" />}
-        {...scrollProps}
       >
         {children}
       </Scrollbars>

+ 0 - 4
packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap

@@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
       Object {
         "display": "none",
         "height": 6,
-        "opacity": 0,
         "position": "absolute",
-        "transition": "opacity 200ms",
       }
     }
   >
@@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
     style={
       Object {
         "display": "none",
-        "opacity": 0,
         "position": "absolute",
-        "transition": "opacity 200ms",
         "width": 6,
       }
     }

+ 24 - 0
packages/grafana-ui/src/components/FormField/FormField.test.tsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { FormField, Props } from './FormField';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    label: 'Test',
+    labelWidth: 11,
+    value: 10,
+    onChange: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<FormField {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 25 - 0
packages/grafana-ui/src/components/FormField/FormField.tsx

@@ -0,0 +1,25 @@
+import React, { InputHTMLAttributes, FunctionComponent } from 'react';
+import { FormLabel } from '..';
+
+export interface Props extends InputHTMLAttributes<HTMLInputElement> {
+  label: string;
+  labelWidth?: number;
+  inputWidth?: number;
+}
+
+const defaultProps = {
+  labelWidth: 6,
+  inputWidth: 12,
+};
+
+const FormField: FunctionComponent<Props> = ({ label, labelWidth, inputWidth, ...inputProps }) => {
+  return (
+    <div className="form-field">
+      <FormLabel width={labelWidth}>{label}</FormLabel>
+      <input type="text" className={`gf-form-input width-${inputWidth}`} {...inputProps} />
+    </div>
+  );
+};
+
+FormField.defaultProps = defaultProps;
+export { FormField };

+ 12 - 0
packages/grafana-ui/src/components/FormField/_FormField.scss

@@ -0,0 +1,12 @@
+.form-field {
+  margin-bottom: $gf-form-margin;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  text-align: left;
+  position: relative;
+
+  &--grow {
+    flex-grow: 1;
+  }
+}

+ 19 - 0
packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap

@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="form-field"
+>
+  <Component
+    width={11}
+  >
+    Test
+  </Component>
+  <input
+    className="gf-form-input width-12"
+    onChange={[MockFunction]}
+    type="text"
+    value={10}
+  />
+</div>
+`;

+ 42 - 0
packages/grafana-ui/src/components/FormLabel/FormLabel.tsx

@@ -0,0 +1,42 @@
+import React, { FunctionComponent, ReactNode } from 'react';
+import classNames from 'classnames';
+import { Tooltip } from '..';
+
+interface Props {
+  children: ReactNode;
+  className?: string;
+  htmlFor?: string;
+  isFocused?: boolean;
+  isInvalid?: boolean;
+  tooltip?: string;
+  width?: number;
+}
+
+export const FormLabel: FunctionComponent<Props> = ({
+  children,
+  isFocused,
+  isInvalid,
+  className,
+  htmlFor,
+  tooltip,
+  width,
+  ...rest
+}) => {
+  const classes = classNames(`gf-form-label width-${width ? width : '10'}`, className, {
+    'gf-form-label--is-focused': isFocused,
+    'gf-form-label--is-invalid': isInvalid,
+  });
+
+  return (
+    <label className={classes} {...rest} htmlFor={htmlFor}>
+      {children}
+      {tooltip && (
+        <Tooltip placement="auto" content={tooltip}>
+          <div className="gf-form-help-icon--right-normal">
+            <i className="gicon gicon-question gicon--has-hover" />
+          </div>
+        </Tooltip>
+      )}
+    </label>
+  );
+};

+ 147 - 0
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx

@@ -0,0 +1,147 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { Gauge, Props } from './Gauge';
+import { TimeSeriesVMs } from '../../types/series';
+import { ValueMapping, MappingType } from '../../types';
+
+jest.mock('jquery', () => ({
+  plot: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    maxValue: 100,
+    valueMappings: [],
+    minValue: 0,
+    prefix: '',
+    showThresholdMarkers: true,
+    showThresholdLabels: false,
+    suffix: '',
+    thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
+    unit: 'none',
+    stat: 'avg',
+    height: 300,
+    width: 300,
+    timeSeries: {} as TimeSeriesVMs,
+    decimals: 0,
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<Gauge {...props} />);
+  const instance = wrapper.instance() as Gauge;
+
+  return {
+    instance,
+    wrapper,
+  };
+};
+
+describe('Get font color', () => {
+  it('should get first threshold color when only one threshold', () => {
+    const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
+
+    expect(instance.getFontColor(49)).toEqual('#7EB26D');
+  });
+
+  it('should get the threshold color if value is same as a threshold', () => {
+    const { instance } = setup({
+      thresholds: [
+        { index: 2, value: 75, color: '#6ED0E0' },
+        { index: 1, value: 50, color: '#EAB839' },
+        { index: 0, value: -Infinity, color: '#7EB26D' },
+      ],
+    });
+
+    expect(instance.getFontColor(50)).toEqual('#EAB839');
+  });
+
+  it('should get the nearest threshold color between thresholds', () => {
+    const { instance } = setup({
+      thresholds: [
+        { index: 2, value: 75, color: '#6ED0E0' },
+        { index: 1, value: 50, color: '#EAB839' },
+        { index: 0, value: -Infinity, color: '#7EB26D' },
+      ],
+    });
+
+    expect(instance.getFontColor(55)).toEqual('#EAB839');
+  });
+});
+
+describe('Get thresholds formatted', () => {
+  it('should return first thresholds color for min and max', () => {
+    const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
+
+    expect(instance.getFormattedThresholds()).toEqual([
+      { value: 0, color: '#7EB26D' },
+      { value: 100, color: '#7EB26D' },
+    ]);
+  });
+
+  it('should get the correct formatted values when thresholds are added', () => {
+    const { instance } = setup({
+      thresholds: [
+        { index: 2, value: 75, color: '#6ED0E0' },
+        { index: 1, value: 50, color: '#EAB839' },
+        { index: 0, value: -Infinity, color: '#7EB26D' },
+      ],
+    });
+
+    expect(instance.getFormattedThresholds()).toEqual([
+      { value: 0, color: '#7EB26D' },
+      { value: 50, color: '#7EB26D' },
+      { value: 75, color: '#EAB839' },
+      { value: 100, color: '#6ED0E0' },
+    ]);
+  });
+});
+
+describe('Format value', () => {
+  it('should return if value isNaN', () => {
+    const valueMappings: ValueMapping[] = [];
+    const value = 'N/A';
+    const { instance } = setup({ valueMappings });
+
+    const result = instance.formatValue(value);
+
+    expect(result).toEqual('N/A');
+  });
+
+  it('should return formatted value if there are no value mappings', () => {
+    const valueMappings: ValueMapping[] = [];
+    const value = '6';
+    const { instance } = setup({ valueMappings, decimals: 1 });
+
+    const result = instance.formatValue(value);
+
+    expect(result).toEqual(' 6.0 ');
+  });
+
+  it('should return formatted value if there are no matching value mappings', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+      { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
+    ];
+    const value = '10';
+    const { instance } = setup({ valueMappings, decimals: 1 });
+
+    const result = instance.formatValue(value);
+
+    expect(result).toEqual(' 10.0 ');
+  });
+
+  it('should return mapped value if there are matching value mappings', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+      { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+    ];
+    const value = '11';
+    const { instance } = setup({ valueMappings, decimals: 1 });
+
+    const result = instance.formatValue(value);
+
+    expect(result).toEqual(' 1-20 ');
+  });
+});

+ 60 - 76
public/app/viz/Gauge.tsx → packages/grafana-ui/src/components/Gauge/Gauge.tsx

@@ -1,15 +1,15 @@
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
-import { BasicGaugeColor, Threshold, TimeSeriesVMs, RangeMap, ValueMap, MappingType } from '@grafana/ui';
 
-import config from '../core/config';
-import kbn from '../core/utils/kbn';
+import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
+import { TimeSeriesVMs } from '../../types/series';
+import { getValueFormat } from '../../utils/valueFormats/valueFormats';
+import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
 
 export interface Props {
-  baseColor: string;
   decimals: number;
   height: number;
-  mappings: Array<RangeMap | ValueMap>;
+  valueMappings: ValueMapping[];
   maxValue: number;
   minValue: number;
   prefix: string;
@@ -21,15 +21,15 @@ export interface Props {
   suffix: string;
   unit: string;
   width: number;
+  theme?: ThemeName;
 }
 
 export class Gauge extends PureComponent<Props> {
   canvasElement: any;
 
   static defaultProps = {
-    baseColor: BasicGaugeColor.Green,
     maxValue: 100,
-    mappings: [],
+    valueMappings: [],
     minValue: 0,
     prefix: '',
     showThresholdMarkers: true,
@@ -38,6 +38,7 @@ export class Gauge extends PureComponent<Props> {
     thresholds: [],
     unit: 'none',
     stat: 'avg',
+    theme: ThemeNames.Dark,
   };
 
   componentDidMount() {
@@ -48,91 +49,93 @@ export class Gauge extends PureComponent<Props> {
     this.draw();
   }
 
-  formatWithMappings(mappings, value) {
-    const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
-    const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
+  formatValue(value: TimeSeriesValue) {
+    const { decimals, valueMappings, prefix, suffix, unit } = this.props;
 
-    const valueMap = valueMaps.map(mapping => {
-      if (mapping.value && value === mapping.value) {
-        return mapping.text;
-      }
-    })[0];
+    if (isNaN(value as number)) {
+      return value;
+    }
 
-    const rangeMap = rangeMaps.map(mapping => {
-      if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
-        return mapping.text;
+    if (valueMappings.length > 0) {
+      const valueMappedValue = getMappedValue(valueMappings, value);
+      if (valueMappedValue) {
+        return `${prefix} ${valueMappedValue.text} ${suffix}`;
       }
-    })[0];
+    }
 
-    return {
-      rangeMap,
-      valueMap,
-    };
-  }
+    const formatFunc = getValueFormat(unit);
+    const formattedValue = formatFunc(value as number, decimals);
+    const handleNoValueValue = formattedValue || 'no value';
 
-  formatValue(value) {
-    const { decimals, mappings, prefix, suffix, unit } = this.props;
+    return `${prefix} ${handleNoValueValue} ${suffix}`;
+  }
 
-    const formatFunc = kbn.valueFormats[unit];
-    const formattedValue = formatFunc(value, decimals);
+  getFontColor(value: TimeSeriesValue) {
+    const { thresholds } = this.props;
 
-    if (mappings.length > 0) {
-      const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
+    if (thresholds.length === 1) {
+      return thresholds[0].color;
+    }
 
-      if (valueMap) {
-        return valueMap;
-      } else if (rangeMap) {
-        return rangeMap;
-      }
+    const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
+    if (atThreshold) {
+      return atThreshold.color;
     }
 
-    if (isNaN(value)) {
-      return '-';
+    const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
+
+    if (belowThreshold.length > 0) {
+      const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
+      return nearestThreshold.color;
     }
 
-    return `${prefix} ${formattedValue} ${suffix}`;
+    return BasicGaugeColor.Red;
   }
 
-  getFontColor(value) {
-    const { baseColor, maxValue, thresholds } = this.props;
+  getFormattedThresholds() {
+    const { maxValue, minValue, thresholds } = this.props;
 
-    if (thresholds.length > 0) {
-      const atThreshold = thresholds.filter(threshold => value <= threshold.value);
+    const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
+    const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
 
-      if (atThreshold.length > 0) {
-        return atThreshold[0].color;
-      } else if (value <= maxValue) {
-        return BasicGaugeColor.Red;
-      }
-    }
+    const formattedThresholds = [
+      ...thresholdsSortedByIndex.map(threshold => {
+        if (threshold.index === 0) {
+          return { value: minValue, color: threshold.color };
+        }
+
+        const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
+        return { value: threshold.value, color: previousThreshold.color };
+      }),
+      { value: maxValue, color: lastThreshold.color },
+    ];
 
-    return baseColor;
+    return formattedThresholds;
   }
 
   draw() {
     const {
-      baseColor,
       maxValue,
       minValue,
       timeSeries,
       showThresholdLabels,
       showThresholdMarkers,
-      thresholds,
       width,
       height,
       stat,
+      theme,
     } = this.props;
 
-    let value: string | number = '';
+    let value: TimeSeriesValue = '';
 
     if (timeSeries[0]) {
       value = timeSeries[0].stats[stat];
     } else {
-      value = 'N/A';
+      value = null;
     }
 
     const dimension = Math.min(width, height * 1.3);
-    const backgroundColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
+    const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
     const fontScale = parseInt('80', 10) / 100;
     const fontSize = Math.min(dimension / 5, 100) * fontScale;
     const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
@@ -140,20 +143,6 @@ export class Gauge extends PureComponent<Props> {
     const thresholdMarkersWidth = gaugeWidth / 5;
     const thresholdLabelFontSize = fontSize / 2.5;
 
-    const formattedThresholds = [
-      { value: minValue, color: BasicGaugeColor.Green },
-      ...thresholds.map((threshold, index) => {
-        return {
-          value: threshold.value,
-          color: index === 0 ? threshold.color : thresholds[index].color,
-        };
-      }),
-      {
-        value: maxValue,
-        color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor,
-      },
-    ];
-
     const options = {
       series: {
         gauges: {
@@ -170,7 +159,7 @@ export class Gauge extends PureComponent<Props> {
           layout: { margin: 0, thresholdWidth: 0 },
           cell: { border: { width: 0 } },
           threshold: {
-            values: formattedThresholds,
+            values: this.getFormattedThresholds(),
             label: {
               show: showThresholdLabels,
               margin: thresholdMarkersWidth + 1,
@@ -184,19 +173,14 @@ export class Gauge extends PureComponent<Props> {
             formatter: () => {
               return this.formatValue(value);
             },
-            font: {
-              size: fontSize,
-              family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
-            },
+            font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
           },
           show: true,
         },
       },
     };
 
-    const plotSeries = {
-      data: [[0, value]],
-    };
+    const plotSeries = { data: [[0, value]] };
 
     try {
       $.plot(this.canvasElement, [plotSeries], options);

+ 0 - 23
packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx

@@ -1,23 +0,0 @@
-import React, { SFC, ReactNode } from 'react';
-import classNames from 'classnames';
-
-interface Props {
-  children: ReactNode;
-  htmlFor?: string;
-  className?: string;
-  isFocused?: boolean;
-  isInvalid?: boolean;
-}
-
-export const GfFormLabel: SFC<Props> = ({ children, isFocused, isInvalid, className, htmlFor, ...rest }) => {
-  const classes = classNames('gf-form-label', className, {
-    'gf-form-label--is-focused': isFocused,
-    'gf-form-label--is-invalid': isInvalid,
-  });
-
-  return (
-    <label className={classes} {...rest} htmlFor={htmlFor}>
-      {children}
-    </label>
-  );
-};

+ 1 - 1
packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss

@@ -6,7 +6,7 @@
 }
 
 .panel-options-group__header {
-  padding: 4px 20px;
+  padding: 4px 8px;
   font-size: 1.1rem;
   background: $panel-options-group-header-bg;
   position: relative;

+ 3 - 3
packages/grafana-ui/src/components/Select/Select.tsx

@@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup';
 import IndicatorsContainer from './IndicatorsContainer';
 import NoOptionsMessage from './NoOptionsMessage';
 import resetSelectStyles from './resetSelectStyles';
-import { CustomScrollbar } from '@grafana/ui';
+import { CustomScrollbar } from '..';
 
 export interface SelectOptionItem {
   label?: string;
@@ -61,7 +61,7 @@ interface AsyncProps {
 export const MenuList = (props: any) => {
   return (
     <components.MenuList {...props}>
-      <CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
+      <CustomScrollbar autoHide={false} autoHeightMax="inherit">{props.children}</CustomScrollbar>
     </components.MenuList>
   );
 };
@@ -202,7 +202,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
         classNamePrefix="gf-form-select-box"
         className={selectClassNames}
         components={{
-          Option,
+          Option: SelectOption,
           SingleValue,
           IndicatorsContainer,
           NoOptionsMessage,

+ 1 - 0
packages/grafana-ui/src/components/Select/_Select.scss

@@ -102,6 +102,7 @@ $select-input-bg-disabled: $input-bg-disabled;
 .gf-form-select-box__value-container {
   display: table-cell;
   padding: 6px 10px;
+  vertical-align: middle;
   > div {
     display: inline-block;
   }

+ 126 - 16
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 import { shallow } from 'enzyme';
 
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
-import { BasicGaugeColor } from '../../types';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
@@ -15,49 +14,160 @@ const setup = (propOverrides?: object) => {
   return shallow(<ThresholdsEditor {...props} />).instance() as ThresholdsEditor;
 };
 
+describe('Initialization', () => {
+  it('should add a base threshold if missing', () => {
+    const instance = setup();
+
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+  });
+});
+
 describe('Add threshold', () => {
-  it('should add threshold', () => {
+  it('should not add threshold at index 0', () => {
     const instance = setup();
 
     instance.onAddThreshold(0);
 
-    expect(instance.state.thresholds).toEqual([{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }]);
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+  });
+
+  it('should add threshold', () => {
+    const instance = setup();
+
+    instance.onAddThreshold(1);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ]);
   });
 
   it('should add another threshold above a first', () => {
     const instance = setup({
-      thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }],
+      thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
     });
 
-    instance.onAddThreshold(1);
+    instance.onAddThreshold(2);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 2, value: 75, color: '#6ED0E0' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ]);
+  });
+
+  it('should add another threshold between first and second index', () => {
+    const instance = setup({
+      thresholds: [
+        { index: 0, value: -Infinity, color: '#7EB26D' },
+        { index: 1, value: 50, color: '#EAB839' },
+        { index: 2, value: 75, color: '#6ED0E0' },
+      ],
+    });
+
+    instance.onAddThreshold(2);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 1, value: 75, color: 'rgb(170, 95, 61)' },
-      { index: 0, value: 50, color: 'rgb(127, 115, 64)' },
+      { index: 3, value: 75, color: '#6ED0E0' },
+      { index: 2, value: 62.5, color: '#EF843C' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ]);
+  });
+});
+
+describe('Remove threshold', () => {
+  it('should not remove threshold at index 0', () => {
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+    const instance = setup({ thresholds });
+
+    instance.onRemoveThreshold(thresholds[0]);
+
+    expect(instance.state.thresholds).toEqual(thresholds);
+  });
+
+  it('should remove threshold', () => {
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+    const instance = setup({
+      thresholds,
+    });
+
+    instance.onRemoveThreshold(thresholds[1]);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 75, color: '#6ED0E0' },
     ]);
   });
 });
 
 describe('change threshold value', () => {
-  it('should update value and resort rows', () => {
+  it('should not change threshold at index 0', () => {
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+    const instance = setup({ thresholds });
+
+    const mockEvent = { target: { value: 12 } };
+
+    instance.onChangeThresholdValue(mockEvent, thresholds[0]);
+
+    expect(instance.state.thresholds).toEqual(thresholds);
+  });
+
+  it('should update value', () => {
     const instance = setup();
-    const mockThresholds = [
-      { index: 0, value: 50, color: 'rgba(237, 129, 40, 0.89)' },
-      { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
     ];
 
     instance.state = {
-      baseColor: BasicGaugeColor.Green,
-      thresholds: mockThresholds,
+      thresholds,
     };
 
     const mockEvent = { target: { value: 78 } };
 
-    instance.onChangeThresholdValue(mockEvent, mockThresholds[0]);
+    instance.onChangeThresholdValue(mockEvent, thresholds[1]);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 78, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ]);
+  });
+});
+
+describe('on blur threshold value', () => {
+  it('should resort rows and update indexes', () => {
+    const instance = setup();
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 78, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+
+    instance.state = {
+      thresholds,
+    };
+
+    instance.onBlur();
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: 78, color: 'rgba(237, 129, 40, 0.89)' },
-      { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, value: 78, color: '#EAB839' },
+      { index: 1, value: 75, color: '#6ED0E0' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
     ]);
   });
 });

+ 101 - 112
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx

@@ -1,9 +1,10 @@
 import React, { PureComponent } from 'react';
-import tinycolor, { ColorInput } from 'tinycolor2';
+// import tinycolor, { ColorInput } from 'tinycolor2';
 
-import { Threshold, BasicGaugeColor } from '../../types';
+import { Threshold } from '../../types';
 import { ColorPicker } from '../ColorPicker/ColorPicker';
 import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+import { colors } from '../../utils';
 
 export interface Props {
   thresholds: Threshold[];
@@ -12,50 +13,49 @@ export interface Props {
 
 interface State {
   thresholds: Threshold[];
-  baseColor: string;
 }
 
 export class ThresholdsEditor extends PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
 
-    this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green };
+    const addDefaultThreshold = this.props.thresholds.length === 0;
+    const thresholds: Threshold[] = addDefaultThreshold
+      ? [{ index: 0, value: -Infinity, color: colors[0] }]
+      : props.thresholds;
+    this.state = { thresholds };
+
+    if (addDefaultThreshold) {
+      this.onChange();
+    }
   }
 
   onAddThreshold = (index: number) => {
-    const maxValue = 100; // hardcoded for now before we add the base threshold
-    const minValue = 0; // hardcoded for now before we add the base threshold
     const { thresholds } = this.state;
+    const maxValue = 100;
+    const minValue = 0;
+
+    if (index === 0) {
+      return;
+    }
 
     const newThresholds = thresholds.map(threshold => {
       if (threshold.index >= index) {
-        threshold = {
-          ...threshold,
-          index: threshold.index + 1,
-        };
+        const index = threshold.index + 1;
+        threshold = { ...threshold, index };
       }
-
       return threshold;
     });
 
     // Setting value to a value between the previous thresholds
-    let value;
-
-    if (index === 0 && thresholds.length === 0) {
-      value = maxValue - (maxValue - minValue) / 2;
-    } else if (index === 0 && thresholds.length > 0) {
-      value = newThresholds[index + 1].value - (newThresholds[index + 1].value - minValue) / 2;
-    } else if (index > newThresholds[newThresholds.length - 1].index) {
-      value = maxValue - (maxValue - newThresholds[index - 1].value) / 2;
-    }
+    const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0];
+    const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0];
+    const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue;
+    const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue;
+    const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
 
-    // Set a color that lies between the previous thresholds
-    let color;
-    if (index === 0 && thresholds.length === 0) {
-      color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString();
-    } else {
-      color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString();
-    }
+    // Set a color
+    const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0];
 
     this.setState(
       {
@@ -68,23 +68,45 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
           },
         ]),
       },
-      () => this.updateGauge()
+      () => this.onChange()
     );
   };
 
   onRemoveThreshold = (threshold: Threshold) => {
+    if (threshold.index === 0) {
+      return;
+    }
+
     this.setState(
-      prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }),
-      () => this.updateGauge()
+      prevState => {
+        const newThresholds = prevState.thresholds.map(t => {
+          if (t.index > threshold.index) {
+            const index = t.index - 1;
+            t = { ...t, index };
+          }
+          return t;
+        });
+
+        return {
+          thresholds: newThresholds.filter(t => t !== threshold),
+        };
+      },
+      () => this.onChange()
     );
   };
 
   onChangeThresholdValue = (event: any, threshold: Threshold) => {
+    if (threshold.index === 0) {
+      return;
+    }
+
     const { thresholds } = this.state;
+    const parsedValue = parseInt(event.target.value, 10);
+    const value = isNaN(parsedValue) ? null : parsedValue;
 
     const newThresholds = thresholds.map(t => {
-      if (t === threshold) {
-        t = { ...t, value: event.target.value };
+      if (t === threshold && t.index !== 0) {
+        t = { ...t, value: value as number };
       }
 
       return t;
@@ -108,18 +130,24 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
       {
         thresholds: newThresholds,
       },
-      () => this.updateGauge()
+      () => this.onChange()
     );
   };
 
-  onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
   onBlur = () => {
-    this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) }));
+    this.setState(prevState => {
+      const sortThresholds = this.sortThresholds([...prevState.thresholds]);
+      let index = sortThresholds.length - 1;
+      sortThresholds.forEach(t => {
+        t.index = index--;
+      });
+      return { thresholds: sortThresholds };
+    });
 
-    this.updateGauge();
+    this.onChange();
   };
 
-  updateGauge = () => {
+  onChange = () => {
     this.props.onChange(this.state.thresholds);
   };
 
@@ -129,92 +157,53 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
     });
   };
 
-  renderThresholds() {
-    const { thresholds } = this.state;
-
-    return thresholds.map((threshold, index) => {
-      return (
-        <div className="threshold-row" key={`${threshold.index}-${index}`}>
-          <div className="threshold-row-inner">
-            <div className="threshold-row-color">
-              {threshold.color && (
-                <div className="threshold-row-color-inner">
-                  <ColorPicker
-                    color={threshold.color}
-                    onChange={color => this.onChangeThresholdColor(threshold, color)}
-                  />
-                </div>
-              )}
-            </div>
-            <input
-              className="threshold-row-input"
-              type="text"
-              onChange={event => this.onChangeThresholdValue(event, threshold)}
-              value={threshold.value}
-              onBlur={this.onBlur}
-            />
-            <div onClick={() => this.onRemoveThreshold(threshold)} className="threshold-row-remove">
-              <i className="fa fa-times" />
+  renderInput = (threshold: Threshold) => {
+    const value = threshold.index === 0 ? 'Base' : threshold.value;
+    return (
+      <div className="thresholds-row-input-inner">
+        <span className="thresholds-row-input-inner-arrow" />
+        <div className="thresholds-row-input-inner-color">
+          {threshold.color && (
+            <div className="thresholds-row-input-inner-color-colorpicker">
+              <ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
             </div>
-          </div>
+          )}
         </div>
-      );
-    });
-  }
-
-  renderIndicator() {
-    const { thresholds } = this.state;
-
-    return thresholds.map((t, i) => {
-      return (
-        <div key={`${t.value}-${i}`} className="indicator-section">
-          <div onClick={() => this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} />
-          <div onClick={() => this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} />
+        <div className="thresholds-row-input-inner-value">
+          <input
+            type="text"
+            onChange={event => this.onChangeThresholdValue(event, threshold)}
+            value={value}
+            onBlur={this.onBlur}
+            readOnly={threshold.index === 0}
+          />
         </div>
-      );
-    });
-  }
-
-  renderBaseIndicator() {
-    return (
-      <div className="indicator-section" style={{ height: '100%' }}>
-        <div
-          onClick={() => this.onAddThreshold(0)}
-          style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }}
-        />
-      </div>
-    );
-  }
-
-  renderBase() {
-    const baseColor = BasicGaugeColor.Green;
-
-    return (
-      <div className="threshold-row threshold-row-base">
-        <div className="threshold-row-inner threshold-row-inner--base">
-          <div className="threshold-row-color">
-            <div className="threshold-row-color-inner">
-              <ColorPicker color={baseColor} onChange={color => this.onChangeBaseColor(color)} />
-            </div>
+        {threshold.index > 0 && (
+          <div className="thresholds-row-input-inner-remove" onClick={() => this.onRemoveThreshold(threshold)}>
+            <i className="fa fa-times" />
           </div>
-          <div className="threshold-row-label">Base</div>
-        </div>
+        )}
       </div>
     );
-  }
+  };
 
   render() {
+    const { thresholds } = this.state;
+
     return (
       <PanelOptionsGroup title="Thresholds">
         <div className="thresholds">
-          <div className="color-indicators">
-            {this.renderIndicator()}
-            {this.renderBaseIndicator()}
-          </div>
-          <div className="threshold-rows">
-            {this.renderThresholds()}
-            {this.renderBase()}
-          </div>
+          {thresholds.map((threshold, index) => {
+            return (
+              <div className="thresholds-row" key={`${threshold.index}-${index}`}>
+                <div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}>
+                  <i className="fa fa-plus" />
+                </div>
+                <div className="thresholds-row-color-indicator" style={{ backgroundColor: threshold.color }} />
+                <div className="thresholds-row-input">{this.renderInput(threshold)}</div>
+              </div>
+            );
+          })}
         </div>
       </PanelOptionsGroup>
     );

+ 69 - 67
packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss

@@ -1,103 +1,105 @@
 .thresholds {
+  margin-bottom: 10px;
+}
+
+.thresholds-row {
   display: flex;
+  flex-direction: row;
+  height: 70px;
 }
 
-.threshold-rows {
-  margin-left: 5px;
+.thresholds-row:first-child > .thresholds-row-color-indicator {
+  border-top-left-radius: $border-radius;
+  border-top-right-radius: $border-radius;
+  overflow: hidden;
 }
 
-.threshold-row {
+.thresholds-row:last-child > .thresholds-row-color-indicator {
+  border-bottom-left-radius: $border-radius;
+  border-bottom-right-radius: $border-radius;
+  overflow: hidden;
+}
+
+.thresholds-row-add-button {
+  align-self: center;
+  margin-right: 5px;
+  color: $green;
+  height: 24px;
+  width: 24px;
+  background-color: $green;
+  border-radius: 50%;
   display: flex;
   align-items: center;
-  margin-top: 3px;
-  padding: 5px;
+  justify-content: center;
+  cursor: pointer;
+}
 
-  &::before {
-    font-family: 'FontAwesome';
-    content: '\f0d9';
-    color: $input-label-border-color;
-  }
+.thresholds-row-add-button > i {
+  color: $white;
 }
 
-.threshold-row-inner {
-  border: 1px solid $input-label-border-color;
-  border-radius: $border-radius;
-  display: flex;
-  overflow: hidden;
-  height: 37px;
+.thresholds-row-color-indicator {
+  width: 10px;
+}
 
-  &--base {
-    width: auto;
-  }
+.thresholds-row-input {
+  margin-top: 49px;
+  margin-left: 2px;
 }
 
-.threshold-row-color {
-  width: 36px;
-  border-right: 1px solid $input-label-border-color;
+.thresholds-row-input-inner {
   display: flex;
-  align-items: center;
   justify-content: center;
-  background-color: $input-bg;
+  flex-direction: row;
 }
 
-.threshold-row-color-inner {
-  border-radius: 10px;
-  overflow: hidden;
-  display: flex;
-  align-items: center;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+.thresholds-row-input-inner > *:last-child {
+  border-top-right-radius: $border-radius;
+  border-bottom-right-radius: $border-radius;
 }
 
-.threshold-row-input {
-  padding: 8px 10px;
-  width: 150px;
+.thresholds-row-input-inner-arrow {
+  align-self: center;
+  width: 0;
+  height: 0;
+  border-top: 6px solid transparent;
+  border-bottom: 6px solid transparent;
+  border-right: 6px solid $input-label-border-color;
 }
 
-.threshold-row-label {
-  background-color: $input-label-bg;
-  padding: 5px;
-  display: flex;
-  align-items: center;
+.thresholds-row-input-inner-value > input {
+  height: $gf-form-input-height;
+  padding: $input-padding-y $input-padding-x;
+  width: 150px;
+  border-top: 1px solid $input-label-border-color;
+  border-bottom: 1px solid $input-label-border-color;
 }
 
-.threshold-row-add-label {
-  align-items: center;
+.thresholds-row-input-inner-color {
+  width: 42px;
   display: flex;
-  padding: 5px 8px;
+  align-items: center;
+  justify-content: center;
+  background-color: $input-bg;
+  border: 1px solid $input-label-border-color;
 }
 
-.threshold-row-remove {
+.thresholds-row-input-inner-color-colorpicker {
+  border-radius: 10px;
+  overflow: hidden;
   display: flex;
   align-items: center;
-  justify-content: center;
-  height: 37px;
-  width: 37px;
-  cursor: pointer;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
 }
 
-.threshold-row-add {
-  border-right: $border-width solid $input-label-border-color;
+.thresholds-row-input-inner-remove {
   display: flex;
   align-items: center;
   justify-content: center;
-  width: 36px;
-  background-color: $green;
-}
-
-.threshold-row-label {
-  border-top-left-radius: 0;
-  border-bottom-left-radius: 0;
-}
-
-.indicator-section {
-  width: 100%;
-  height: 50px;
+  height: $gf-form-input-height;
+  padding: $input-padding-y $input-padding-x;
+  width: 42px;
+  background-color: $input-label-bg;
+  border: 1px solid $input-label-border-color;
   cursor: pointer;
 }
-
-.color-indicators {
-  width: 15px;
-  border-bottom-left-radius: $border-radius;
-  border-bottom-right-radius: $border-radius;
-  overflow: hidden;
-}

+ 48 - 53
public/app/plugins/panel/gauge/MappingRow.tsx → packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx

@@ -1,22 +1,22 @@
-import React, { PureComponent } from 'react';
-import { MappingType, RangeMap, Select, ValueMap } from '@grafana/ui';
+import React, { ChangeEvent, PureComponent } from 'react';
 
-import { Label } from 'app/core/components/Label/Label';
+import { MappingType, ValueMapping } from '../../types';
+import { FormField, FormLabel, Select } from '..';
 
-interface Props {
-  mapping: ValueMap | RangeMap;
-  updateMapping: (mapping) => void;
-  removeMapping: () => void;
+export interface Props {
+  valueMapping: ValueMapping;
+  updateValueMapping: (valueMapping: ValueMapping) => void;
+  removeValueMapping: () => void;
 }
 
 interface State {
-  from: string;
+  from?: string;
   id: number;
   operator: string;
   text: string;
-  to: string;
+  to?: string;
   type: MappingType;
-  value: string;
+  value?: string;
 }
 
 const mappingOptions = [
@@ -25,36 +25,34 @@ const mappingOptions = [
 ];
 
 export default class MappingRow extends PureComponent<Props, State> {
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
 
-    this.state = {
-      ...props.mapping,
-    };
+    this.state = { ...props.valueMapping };
   }
 
-  onMappingValueChange = event => {
+  onMappingValueChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ value: event.target.value });
   };
 
-  onMappingFromChange = event => {
+  onMappingFromChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ from: event.target.value });
   };
 
-  onMappingToChange = event => {
+  onMappingToChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ to: event.target.value });
   };
 
-  onMappingTextChange = event => {
+  onMappingTextChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ text: event.target.value });
   };
 
-  onMappingTypeChange = mappingType => {
+  onMappingTypeChange = (mappingType: MappingType) => {
     this.setState({ type: mappingType });
   };
 
   updateMapping = () => {
-    this.props.updateMapping({ ...this.state });
+    this.props.updateValueMapping({ ...this.state } as ValueMapping);
   };
 
   renderRow() {
@@ -63,30 +61,28 @@ export default class MappingRow extends PureComponent<Props, State> {
     if (type === MappingType.RangeToText) {
       return (
         <>
-          <div className="gf-form">
-            <Label width={4}>From</Label>
-            <input
-              className="gf-form-input width-8"
-              value={from}
-              onBlur={this.updateMapping}
-              onChange={this.onMappingFromChange}
-            />
-          </div>
-          <div className="gf-form">
-            <Label width={4}>To</Label>
+          <FormField
+            label="From"
+            labelWidth={4}
+            inputWidth={8}
+            onBlur={this.updateMapping}
+            onChange={this.onMappingFromChange}
+            value={from}
+          />
+          <FormField
+            label="To"
+            labelWidth={4}
+            inputWidth={8}
+            onBlur={this.updateMapping}
+            onChange={this.onMappingToChange}
+            value={to}
+          />
+          <div className="gf-form gf-form--grow">
+            <FormLabel width={4}>Text</FormLabel>
             <input
-              className="gf-form-input width-8"
-              value={to}
+              className="gf-form-input"
               onBlur={this.updateMapping}
-              onChange={this.onMappingToChange}
-            />
-          </div>
-          <div className="gf-form">
-            <Label width={4}>Text</Label>
-            <input
-              className="gf-form-input width-10"
               value={text}
-              onBlur={this.updateMapping}
               onChange={this.onMappingTextChange}
             />
           </div>
@@ -96,17 +92,16 @@ export default class MappingRow extends PureComponent<Props, State> {
 
     return (
       <>
-        <div className="gf-form">
-          <Label width={4}>Value</Label>
-          <input
-            className="gf-form-input width-8"
-            onBlur={this.updateMapping}
-            onChange={this.onMappingValueChange}
-            value={value}
-          />
-        </div>
+        <FormField
+          label="Value"
+          labelWidth={4}
+          onBlur={this.updateMapping}
+          onChange={this.onMappingValueChange}
+          value={value}
+          inputWidth={8}
+        />
         <div className="gf-form gf-form--grow">
-          <Label width={4}>Text</Label>
+          <FormLabel width={4}>Text</FormLabel>
           <input
             className="gf-form-input"
             onBlur={this.updateMapping}
@@ -124,7 +119,7 @@ export default class MappingRow extends PureComponent<Props, State> {
     return (
       <div className="gf-form-inline">
         <div className="gf-form">
-          <Label width={5}>Type</Label>
+          <FormLabel width={5}>Type</FormLabel>
           <Select
             placeholder="Choose type"
             isSearchable={false}
@@ -136,7 +131,7 @@ export default class MappingRow extends PureComponent<Props, State> {
         </div>
         {this.renderRow()}
         <div className="gf-form">
-          <button onClick={this.props.removeMapping} className="gf-form-label gf-form-label--btn">
+          <button onClick={this.props.removeValueMapping} className="gf-form-label gf-form-label--btn">
             <i className="fa fa-times" />
           </button>
         </div>

+ 14 - 16
public/app/plugins/panel/gauge/ValueMappings.test.tsx → packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx

@@ -1,27 +1,23 @@
 import React from 'react';
 import { shallow } from 'enzyme';
-import { GaugeOptions, MappingType, PanelOptionsProps } from '@grafana/ui';
-import { defaultProps } from 'app/plugins/panel/gauge/GaugePanelOptions';
 
-import ValueMappings from './ValueMappings';
+import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
+import { MappingType } from '../../types/panel';
 
 const setup = (propOverrides?: object) => {
-  const props: PanelOptionsProps<GaugeOptions> = {
+  const props: Props = {
     onChange: jest.fn(),
-    options: {
-      ...defaultProps.options,
-      mappings: [
-        { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
-        { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
-      ],
-    },
+    valueMappings: [
+      { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
+      { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
+    ],
   };
 
   Object.assign(props, propOverrides);
 
-  const wrapper = shallow(<ValueMappings {...props} />);
+  const wrapper = shallow(<ValueMappingsEditor {...props} />);
 
-  const instance = wrapper.instance() as ValueMappings;
+  const instance = wrapper.instance() as ValueMappingsEditor;
 
   return {
     instance,
@@ -40,18 +36,20 @@ describe('Render', () => {
 describe('On remove mapping', () => {
   it('Should remove mapping with id 0', () => {
     const { instance } = setup();
+
     instance.onRemoveMapping(1);
 
-    expect(instance.state.mappings).toEqual([
+    expect(instance.state.valueMappings).toEqual([
       { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
     ]);
   });
 
   it('should remove mapping with id 1', () => {
     const { instance } = setup();
+
     instance.onRemoveMapping(2);
 
-    expect(instance.state.mappings).toEqual([
+    expect(instance.state.valueMappings).toEqual([
       { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
     ]);
   });
@@ -67,7 +65,7 @@ describe('Next id to add', () => {
   });
 
   it('should default to 1', () => {
-    const { instance } = setup({ options: { ...defaultProps.options } });
+    const { instance } = setup({ valueMappings: [] });
 
     expect(instance.state.nextIdToAdd).toEqual(1);
   });

+ 29 - 23
public/app/plugins/panel/gauge/ValueMappings.tsx → packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx

@@ -1,33 +1,39 @@
 import React, { PureComponent } from 'react';
-import { GaugeOptions, PanelOptionsProps, MappingType, RangeMap, ValueMap, PanelOptionsGroup } from '@grafana/ui';
 
 import MappingRow from './MappingRow';
+import { MappingType, ValueMapping } from '../../types/panel';
+import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+
+export interface Props {
+  valueMappings: ValueMapping[];
+  onChange: (valueMappings: ValueMapping[]) => void;
+}
 
 interface State {
-  mappings: Array<ValueMap | RangeMap>;
+  valueMappings: ValueMapping[];
   nextIdToAdd: number;
 }
 
-export default class ValueMappings extends PureComponent<PanelOptionsProps<GaugeOptions>, State> {
-  constructor(props) {
+export class ValueMappingsEditor extends PureComponent<Props, State> {
+  constructor(props: Props) {
     super(props);
 
-    const mappings = props.options.mappings;
+    const mappings = props.valueMappings;
 
     this.state = {
-      mappings: mappings || [],
-      nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
+      valueMappings: mappings,
+      nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromValueMappings(mappings) : 1,
     };
   }
 
-  getMaxIdFromMappings(mappings) {
+  getMaxIdFromValueMappings(mappings: ValueMapping[]) {
     return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
   }
 
   addMapping = () =>
     this.setState(prevState => ({
-      mappings: [
-        ...prevState.mappings,
+      valueMappings: [
+        ...prevState.valueMappings,
         {
           id: prevState.nextIdToAdd,
           operator: '',
@@ -41,23 +47,23 @@ export default class ValueMappings extends PureComponent<PanelOptionsProps<Gauge
       nextIdToAdd: prevState.nextIdToAdd + 1,
     }));
 
-  onRemoveMapping = id => {
+  onRemoveMapping = (id: number) => {
     this.setState(
       prevState => ({
-        mappings: prevState.mappings.filter(m => {
+        valueMappings: prevState.valueMappings.filter(m => {
           return m.id !== id;
         }),
       }),
       () => {
-        this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+        this.props.onChange(this.state.valueMappings);
       }
     );
   };
 
-  updateGauge = mapping => {
+  updateGauge = (mapping: ValueMapping) => {
     this.setState(
       prevState => ({
-        mappings: prevState.mappings.map(m => {
+        valueMappings: prevState.valueMappings.map(m => {
           if (m.id === mapping.id) {
             return { ...mapping };
           }
@@ -66,24 +72,24 @@ export default class ValueMappings extends PureComponent<PanelOptionsProps<Gauge
         }),
       }),
       () => {
-        this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+        this.props.onChange(this.state.valueMappings);
       }
     );
   };
 
   render() {
-    const { mappings } = this.state;
+    const { valueMappings } = this.state;
 
     return (
       <PanelOptionsGroup title="Value Mappings">
         <div>
-          {mappings.length > 0 &&
-            mappings.map((mapping, index) => (
+          {valueMappings.length > 0 &&
+            valueMappings.map((valueMapping, index) => (
               <MappingRow
-                key={`${mapping.text}-${index}`}
-                mapping={mapping}
-                updateMapping={this.updateGauge}
-                removeMapping={() => this.onRemoveMapping(mapping.id)}
+                key={`${valueMapping.text}-${index}`}
+                valueMapping={valueMapping}
+                updateValueMapping={this.updateGauge}
+                removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
               />
             ))}
         </div>

+ 0 - 0
public/sass/components/_value-mappings.scss → packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss


+ 6 - 6
public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap → packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap

@@ -7,7 +7,9 @@ exports[`Render should render component 1`] = `
   <div>
     <MappingRow
       key="Ok-0"
-      mapping={
+      removeValueMapping={[Function]}
+      updateValueMapping={[Function]}
+      valueMapping={
         Object {
           "id": 1,
           "operator": "",
@@ -16,12 +18,12 @@ exports[`Render should render component 1`] = `
           "value": "20",
         }
       }
-      removeMapping={[Function]}
-      updateMapping={[Function]}
     />
     <MappingRow
       key="Meh-1"
-      mapping={
+      removeValueMapping={[Function]}
+      updateValueMapping={[Function]}
+      valueMapping={
         Object {
           "from": "21",
           "id": 2,
@@ -31,8 +33,6 @@ exports[`Render should render component 1`] = `
           "type": 2,
         }
       }
-      removeMapping={[Function]}
-      updateMapping={[Function]}
     />
   </div>
   <div

+ 3 - 0
packages/grafana-ui/src/components/index.scss

@@ -5,3 +5,6 @@
 @import 'Select/Select';
 @import 'PanelOptionsGroup/PanelOptionsGroup';
 @import 'PanelOptionsGrid/PanelOptionsGrid';
+@import 'ColorPicker/ColorPicker';
+@import 'ValueMappingsEditor/ValueMappingsEditor';
+@import "FormField/FormField";

+ 6 - 1
packages/grafana-ui/src/components/index.ts

@@ -9,12 +9,17 @@ export { IndicatorsContainer } from './Select/IndicatorsContainer';
 export { NoOptionsMessage } from './Select/NoOptionsMessage';
 export { default as resetSelectStyles } from './Select/resetSelectStyles';
 
+// Forms
+export { FormLabel } from './FormLabel/FormLabel';
+export { FormField } from './FormField/FormField';
+
 export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
 export { ColorPicker } from './ColorPicker/ColorPicker';
 export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
 export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
 export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
-export { GfFormLabel } from './GfFormLabel/GfFormLabel';
 export { Graph } from './Graph/Graph';
 export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
 export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
+export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
+export { Gauge } from './Gauge/Gauge';

+ 2 - 0
packages/grafana-ui/src/index.scss

@@ -1 +1,3 @@
+@import 'vendor/spectrum';
 @import 'components/index';
+

+ 89 - 0
packages/grafana-ui/src/types/datasource.ts

@@ -0,0 +1,89 @@
+import { TimeRange, RawTimeRange } from './time';
+import { TimeSeries } from './series';
+import { PluginMeta } from './plugin';
+
+export interface DataQueryResponse {
+  data: TimeSeries[];
+}
+
+export interface DataQuery {
+  /**
+   * A - Z
+   */
+  refId: string;
+
+  /**
+   * true if query is disabled (ie not executed / sent to TSDB)
+   */
+  hide?: boolean;
+
+  /**
+   * Unique, guid like, string used in explore mode
+   */
+  key?: string;
+
+  /**
+   * For mixed data sources the selected datasource is on the query level.
+   * For non mixed scenarios this is undefined.
+   */
+  datasource?: string | null;
+}
+
+export interface DataQueryOptions<TQuery extends DataQuery = DataQuery> {
+  timezone: string;
+  range: TimeRange;
+  rangeRaw: RawTimeRange;
+  targets: TQuery[];
+  panelId: number;
+  dashboardId: number;
+  cacheTimeout?: string;
+  interval: string;
+  intervalMs: number;
+  maxDataPoints: number;
+  scopedVars: object;
+}
+
+export interface QueryFix {
+  type: string;
+  label: string;
+  action?: QueryFixAction;
+}
+
+export interface QueryFixAction {
+  type: string;
+  query?: string;
+  preventSubmit?: boolean;
+}
+
+export interface QueryHint {
+  type: string;
+  label: string;
+  fix?: QueryFix;
+}
+
+export interface DataSourceSettings {
+  id: number;
+  orgId: number;
+  name: string;
+  typeLogoUrl: string;
+  type: string;
+  access: string;
+  url: string;
+  password: string;
+  user: string;
+  database: string;
+  basicAuth: boolean;
+  basicAuthPassword: string;
+  basicAuthUser: string;
+  isDefault: boolean;
+  jsonData: { authType: string; defaultRegion: string };
+  readOnly: boolean;
+  withCredentials: boolean;
+}
+
+export interface DataSourceSelectItem {
+  name: string;
+  value: string | null;
+  meta: PluginMeta;
+  sort: string;
+}

+ 0 - 16
packages/grafana-ui/src/types/gauge.ts

@@ -1,16 +0,0 @@
-import { RangeMap, Threshold, ValueMap } from './panel';
-
-export interface GaugeOptions {
-  baseColor: string;
-  decimals: number;
-  mappings: Array<RangeMap | ValueMap>;
-  maxValue: number;
-  minValue: number;
-  prefix: string;
-  showThresholdLabels: boolean;
-  showThresholdMarkers: boolean;
-  stat: string;
-  suffix: string;
-  thresholds: Threshold[];
-  unit: string;
-}

+ 2 - 1
packages/grafana-ui/src/types/index.ts

@@ -1,4 +1,5 @@
 export * from './series';
 export * from './time';
 export * from './panel';
-export * from './gauge';
+export * from './plugin';
+export * from './datasource';

+ 12 - 0
packages/grafana-ui/src/types/panel.ts

@@ -1,6 +1,8 @@
 import { TimeSeries, LoadingState } from './series';
 import { TimeRange } from './time';
 
+export type InterpolateFunction = (value: string, format?: string | Function) => string;
+
 export interface PanelProps<T = any> {
   timeSeries: TimeSeries[];
   timeRange: TimeRange;
@@ -9,6 +11,7 @@ export interface PanelProps<T = any> {
   renderCounter: number;
   width: number;
   height: number;
+  onInterpolate: InterpolateFunction;
 }
 
 export interface PanelOptionsProps<T = any> {
@@ -53,6 +56,8 @@ interface BaseMap {
   type: MappingType;
 }
 
+export type ValueMapping = ValueMap | RangeMap;
+
 export interface ValueMap extends BaseMap {
   value: string;
 }
@@ -61,3 +66,10 @@ export interface RangeMap extends BaseMap {
   from: string;
   to: string;
 }
+
+export type ThemeName = 'dark' | 'light';
+
+export enum ThemeNames {
+  Dark = 'dark',
+  Light = 'light',
+}

+ 118 - 0
packages/grafana-ui/src/types/plugin.ts

@@ -0,0 +1,118 @@
+import { ComponentClass } from 'react';
+import { PanelProps, PanelOptionsProps } from './panel';
+import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource';
+
+export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
+  /**
+   *  min interval range
+   */
+  interval?: string;
+
+  /**
+   * Imports queries from a different datasource
+   */
+  importQueries?(queries: TQuery[], originMeta: PluginMeta): Promise<TQuery[]>;
+
+  /**
+   * Initializes a datasource after instantiation
+   */
+  init?: () => void;
+
+  /**
+   * Main metrics / data query action
+   */
+  query(options: DataQueryOptions<TQuery>): Promise<DataQueryResponse>;
+
+  /**
+   * Test & verify datasource settings & connection details
+   */
+  testDatasource(): Promise<any>;
+
+  /**
+   *  Get hints for query improvements
+   */
+  getQueryHints?(query: TQuery, results: any[], ...rest: any): QueryHint[];
+
+  /**
+   *  Set after constructor is called by Grafana
+   */
+  name?: string;
+  meta?: PluginMeta;
+  pluginExports?: PluginExports;
+}
+
+export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
+  datasource: DSType;
+  query: TQuery;
+  onExecuteQuery?: () => void;
+  onQueryChange?: (value: TQuery) => void;
+}
+
+export interface PluginExports {
+  Datasource?: DataSourceApi;
+  QueryCtrl?: any;
+  QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi,DataQuery>>;
+  ConfigCtrl?: any;
+  AnnotationsQueryCtrl?: any;
+  VariableQueryEditor?: any;
+  ExploreQueryField?: any;
+  ExploreStartPage?: any;
+
+  // Panel plugin
+  PanelCtrl?: any;
+  Panel?: ComponentClass<PanelProps>;
+  PanelOptions?: ComponentClass<PanelOptionsProps>;
+  PanelDefaults?: any;
+}
+
+export interface PluginMeta {
+  id: string;
+  name: string;
+  info: PluginMetaInfo;
+  includes: PluginInclude[];
+
+  // Datasource-specific
+  metrics?: boolean;
+  tables?: boolean;
+  logs?: boolean;
+  explore?: boolean;
+  annotations?: boolean;
+  mixed?: boolean;
+  hasQueryHelp?: boolean;
+  queryOptions?: PluginMetaQueryOptions;
+}
+
+interface PluginMetaQueryOptions {
+  cacheTimeout?: boolean;
+  maxDataPoints?: boolean;
+  minInterval?: boolean;
+}
+
+export interface PluginInclude {
+  type: string;
+  name: string;
+  path: string;
+}
+
+interface PluginMetaInfoLink {
+  name: string;
+  url: string;
+}
+
+export interface PluginMetaInfo {
+  author: {
+    name: string;
+    url?: string;
+  };
+  description: string;
+  links: PluginMetaInfoLink[];
+  logos: {
+    large: string;
+    small: string;
+  };
+  screenshots: any[];
+  updated: string;
+  version: string;
+}
+
+

+ 3 - 2
packages/grafana-ui/src/types/series.ts

@@ -21,9 +21,12 @@ export interface TimeSeriesVM {
   color: string;
   data: TimeSeriesValue[][];
   stats: TimeSeriesStats;
+  allIsNull: boolean;
+  allIsZero: boolean;
 }
 
 export interface TimeSeriesStats {
+  [key: string]: number | null;
   total: number | null;
   max: number | null;
   min: number | null;
@@ -36,8 +39,6 @@ export interface TimeSeriesStats {
   range: number | null;
   timeStep: number;
   count: number;
-  allIsNull: boolean;
-  allIsZero: boolean;
 }
 
 export enum NullValueMode {

+ 9 - 8
packages/grafana-ui/src/utils/processTimeSeries.ts

@@ -1,18 +1,19 @@
 // Libraries
 import _ from 'lodash';
 
+import { colors } from './colors';
+
 // Types
 import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
 
 interface Options {
   timeSeries: TimeSeries[];
   nullValueMode: NullValueMode;
-  colorPalette: string[];
 }
 
-export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
+export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
   const vmSeries = timeSeries.map((item, index) => {
-    const colorIndex = index % colorPalette.length;
+    const colorIndex = index % colors.length;
     const label = item.target;
     const result = [];
 
@@ -49,8 +50,8 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
         continue;
       }
 
-      if (typeof currentValue !== 'number') {
-        continue;
+      if (currentValue !== null && typeof currentValue !== 'number') {
+        throw {message: 'Time series contains non number values'};
       }
 
       // Due to missing values we could have different timeStep all along the series
@@ -150,7 +151,9 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
     return {
       data: result,
       label: label,
-      color: colorPalette[colorIndex],
+      color: colors[colorIndex],
+      allIsZero,
+      allIsNull,
       stats: {
         total,
         min,
@@ -164,8 +167,6 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
         range,
         count,
         first,
-        allIsZero,
-        allIsNull,
       },
     };
   });

+ 81 - 0
packages/grafana-ui/src/utils/valueMappings.test.ts

@@ -0,0 +1,81 @@
+import { getMappedValue } from './valueMappings';
+import { ValueMapping, MappingType } from '../types/panel';
+
+describe('Format value with value mappings', () => {
+  it('should return undefined with no valuemappings', () => {
+    const valueMappings: ValueMapping[] = [];
+    const value = '10';
+
+    expect(getMappedValue(valueMappings, value)).toBeUndefined();
+  });
+
+  it('should return undefined with no matching valuemappings', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+      { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
+    ];
+    const value = '10';
+
+    expect(getMappedValue(valueMappings, value)).toBeUndefined();
+  });
+
+  it('should return first matching mapping with lowest id', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+      { id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' },
+    ];
+    const value = '10';
+
+    expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
+  });
+
+  it('should return if value is null and value to text mapping value is null', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+      { id: 1, operator: '', text: '<NULL>', type: MappingType.ValueToText, value: 'null' },
+    ];
+    const value = null;
+
+    expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>');
+  });
+
+  it('should return if value is null and range to text mapping from and to is null', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '<NULL>', type: MappingType.RangeToText, from: 'null', to: 'null' },
+      { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+    ];
+    const value = null;
+
+    expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>');
+  });
+
+  it('should return rangeToText mapping where value equals to', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' },
+      { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+    ];
+    const value = '10';
+
+    expect(getMappedValue(valueMappings, value).text).toEqual('1-10');
+  });
+
+  it('should return rangeToText mapping where value equals from', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' },
+      { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+    ];
+    const value = '10';
+
+    expect(getMappedValue(valueMappings, value).text).toEqual('10-20');
+  });
+
+  it('should return rangeToText mapping where value is between from and to', () => {
+    const valueMappings: ValueMapping[] = [
+      { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+      { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+    ];
+    const value = '10';
+
+    expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
+  });
+});

+ 89 - 0
packages/grafana-ui/src/utils/valueMappings.ts

@@ -0,0 +1,89 @@
+import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types';
+
+export type TimeSeriesValue = string | number | null;
+
+const addValueToTextMappingText = (
+  allValueMappings: ValueMapping[],
+  valueToTextMapping: ValueMap,
+  value: TimeSeriesValue
+) => {
+  if (valueToTextMapping.value === undefined) {
+    return allValueMappings;
+  }
+
+  if (value === null && valueToTextMapping.value && valueToTextMapping.value.toLowerCase() === 'null') {
+    return allValueMappings.concat(valueToTextMapping);
+  }
+
+  const valueAsNumber = parseFloat(value as string);
+  const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
+
+  if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) {
+    return allValueMappings;
+  }
+
+  if (valueAsNumber !== valueToTextMappingAsNumber) {
+    return allValueMappings;
+  }
+
+  return allValueMappings.concat(valueToTextMapping);
+};
+
+const addRangeToTextMappingText = (
+  allValueMappings: ValueMapping[],
+  rangeToTextMapping: RangeMap,
+  value: TimeSeriesValue
+) => {
+  if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) {
+    return allValueMappings;
+  }
+
+  if (
+    value === null &&
+    rangeToTextMapping.from &&
+    rangeToTextMapping.to &&
+    rangeToTextMapping.from.toLowerCase() === 'null' &&
+    rangeToTextMapping.to.toLowerCase() === 'null'
+  ) {
+    return allValueMappings.concat(rangeToTextMapping);
+  }
+
+  const valueAsNumber = parseFloat(value as string);
+  const fromAsNumber = parseFloat(rangeToTextMapping.from as string);
+  const toAsNumber = parseFloat(rangeToTextMapping.to as string);
+
+  if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) {
+    return allValueMappings;
+  }
+
+  if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) {
+    return allValueMappings.concat(rangeToTextMapping);
+  }
+
+  return allValueMappings;
+};
+
+const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => {
+  const allFormattedValueMappings = valueMappings.reduce(
+    (allValueMappings, valueMapping) => {
+      if (valueMapping.type === MappingType.ValueToText) {
+        allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value);
+      } else if (valueMapping.type === MappingType.RangeToText) {
+        allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value);
+      }
+
+      return allValueMappings;
+    },
+    [] as ValueMapping[]
+  );
+
+  allFormattedValueMappings.sort((t1, t2) => {
+    return t1.id - t2.id;
+  });
+
+  return allFormattedValueMappings;
+};
+
+export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => {
+  return getAllFormattedValueMappings(valueMappings, value)[0];
+};

+ 0 - 0
public/vendor/css/spectrum.css → packages/grafana-ui/src/vendor/spectrum.css


+ 0 - 0
public/vendor/spectrum.js → packages/grafana-ui/src/vendor/spectrum.js


+ 6 - 6
pkg/api/api.go

@@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() {
 
 	// not logged in views
 	r.Get("/", reqSignedIn, hs.Index)
-	r.Get("/logout", Logout)
-	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
-	r.Get("/login/:name", quota("session"), OAuthLogin)
+	r.Get("/logout", hs.Logout)
+	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
+	r.Get("/login/:name", quota("session"), hs.OAuthLogin)
 	r.Get("/login", hs.LoginView)
 	r.Get("/invite/:code", hs.Index)
 
@@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/signup", hs.Index)
 	r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
 	r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
-	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
+	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
 
 	// invited
 	r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
-	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
+	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite))
 
 	// reset password
 	r.Get("/user/password/send-reset-email", hs.Index)
@@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
 
 	// api renew session based on remember cookie
-	r.Get("/api/login/ping", quota("session"), LoginAPIPing)
+	r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
 
 	// authed api
 	r.Group("/api", func(apiRoute routing.RouteRegister) {

+ 32 - 10
pkg/api/common_test.go

@@ -5,7 +5,6 @@ import (
 	"net/http/httptest"
 	"path/filepath"
 
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
@@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
 }
 
 type scenarioContext struct {
-	m              *macaron.Macaron
-	context        *m.ReqContext
-	resp           *httptest.ResponseRecorder
-	handlerFunc    handlerFunc
-	defaultHandler macaron.Handler
-	req            *http.Request
-	url            string
+	m                    *macaron.Macaron
+	context              *m.ReqContext
+	resp                 *httptest.ResponseRecorder
+	handlerFunc          handlerFunc
+	defaultHandler       macaron.Handler
+	req                  *http.Request
+	url                  string
+	userAuthTokenService *fakeUserAuthTokenService
 }
 
 func (sc *scenarioContext) exec() {
@@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
 		Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 	}))
 
-	sc.m.Use(middleware.GetContextHandler())
-	sc.m.Use(middleware.Sessioner(&session.Options{}, 0))
+	sc.userAuthTokenService = newFakeUserAuthTokenService()
+	sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
 
 	return sc
 }
+
+type fakeUserAuthTokenService struct {
+	initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+}
+
+func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
+	return &fakeUserAuthTokenService{
+		initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
+			return false
+		},
+	}
+}
+
+func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
+	return s.initContextWithTokenProvider(ctx, orgID)
+}
+
+func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
+	return nil
+}
+
+func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}

+ 1 - 1
pkg/api/dashboard.go

@@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
 		"id":   123123,
 		"gridPos": map[string]interface{}{
 			"x": 0,
-			"y": 3,
+			"y": 0,
 			"w": 24,
 			"h": 4,
 		},

+ 2 - 0
pkg/api/frontendsettings.go

@@ -165,6 +165,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
 		"externalUserMngInfo":        setting.ExternalUserMngInfo,
 		"externalUserMngLinkUrl":     setting.ExternalUserMngLinkUrl,
 		"externalUserMngLinkName":    setting.ExternalUserMngLinkName,
+		"viewersCanEdit":             setting.ViewersCanEdit,
+		"disableSanitizeHtml":        hs.Cfg.DisableSanitizeHtml,
 		"buildInfo": map[string]interface{}{
 			"version":       setting.BuildVersion,
 			"commit":        setting.BuildCommit,

+ 17 - 16
pkg/api/http_server.go

@@ -11,14 +11,8 @@ import (
 	"path"
 	"time"
 
-	"github.com/grafana/grafana/pkg/api/routing"
-	"github.com/prometheus/client_golang/prometheus"
-
-	"github.com/prometheus/client_golang/prometheus/promhttp"
-
-	macaron "gopkg.in/macaron.v1"
-
 	"github.com/grafana/grafana/pkg/api/live"
+	"github.com/grafana/grafana/pkg/api/routing"
 	httpstatic "github.com/grafana/grafana/pkg/api/static"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -27,11 +21,16 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"github.com/grafana/grafana/pkg/services/cache"
 	"github.com/grafana/grafana/pkg/services/datasources"
 	"github.com/grafana/grafana/pkg/services/hooks"
 	"github.com/grafana/grafana/pkg/services/rendering"
+	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+	macaron "gopkg.in/macaron.v1"
 )
 
 func init() {
@@ -49,13 +48,14 @@ type HTTPServer struct {
 	streamManager *live.StreamManager
 	httpSrv       *http.Server
 
-	RouteRegister   routing.RouteRegister    `inject:""`
-	Bus             bus.Bus                  `inject:""`
-	RenderService   rendering.Service        `inject:""`
-	Cfg             *setting.Cfg             `inject:""`
-	HooksService    *hooks.HooksService      `inject:""`
-	CacheService    *cache.CacheService      `inject:""`
-	DatasourceCache datasources.CacheService `inject:""`
+	RouteRegister    routing.RouteRegister     `inject:""`
+	Bus              bus.Bus                   `inject:""`
+	RenderService    rendering.Service         `inject:""`
+	Cfg              *setting.Cfg              `inject:""`
+	HooksService     *hooks.HooksService       `inject:""`
+	CacheService     *cache.CacheService       `inject:""`
+	DatasourceCache  datasources.CacheService  `inject:""`
+	AuthTokenService auth.UserAuthTokenService `inject:""`
 }
 
 func (hs *HTTPServer) Init() error {
@@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
 	hs.macaron = hs.newMacaron()
 	hs.registerRoutes()
 
+	session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
+
 	return nil
 }
 
@@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
 
 	m.Use(hs.healthHandler)
 	m.Use(hs.metricsEndpoint)
-	m.Use(middleware.GetContextHandler())
-	m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
+	m.Use(middleware.GetContextHandler(hs.AuthTokenService))
 	m.Use(middleware.OrgRedirect())
 
 	// needs to be after context handler

+ 1 - 1
pkg/api/index.go

@@ -140,7 +140,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
 		Children: dashboardChildNavs,
 	})
 
-	if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
+	if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || setting.ViewersCanEdit) {
 		data.NavTree = append(data.NavTree, &dtos.NavLink{
 			Text:     "Explore",
 			Id:       "explore",

+ 59 - 67
pkg/api/login.go

@@ -1,6 +1,8 @@
 package api
 
 import (
+	"encoding/hex"
+	"net/http"
 	"net/url"
 
 	"github.com/grafana/grafana/pkg/api/dtos"
@@ -9,12 +11,13 @@ import (
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 const (
-	ViewIndex = "index"
+	ViewIndex            = "index"
+	LoginErrorCookieName = "login_error"
 )
 
 func (hs *HTTPServer) LoginView(c *m.ReqContext) {
@@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
 	viewData.Settings["loginHint"] = setting.LoginHint
 	viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
 
-	if loginError, ok := c.Session.Get("loginError").(string); ok {
-		c.Session.Delete("loginError")
+	if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
+		deleteCookie(c, LoginErrorCookieName)
 		viewData.Settings["loginError"] = loginError
 	}
 
@@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
 		return
 	}
 
-	if !tryLoginUsingRememberCookie(c) {
+	if !c.IsSignedIn {
 		c.HTML(200, ViewIndex, viewData)
 		return
 	}
@@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
 	return false
 }
 
-func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
-	// Check auto-login.
-	uname := c.GetCookie(setting.CookieUserName)
-	if len(uname) == 0 {
-		return false
-	}
-
-	isSucceed := false
-	defer func() {
-		if !isSucceed {
-			log.Trace("auto-login cookie cleared: %s", uname)
-			c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
-			c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
-			return
-		}
-	}()
-
-	userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
-	if err := bus.Dispatch(&userQuery); err != nil {
-		return false
-	}
-
-	user := userQuery.Result
-
-	// validate remember me cookie
-	signingKey := user.Rands + user.Password
-	if len(signingKey) < 10 {
-		c.Logger.Error("Invalid user signingKey")
-		return false
+func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
+	if c.IsSignedIn || c.IsAnonymous {
+		return JSON(200, "Logged in")
 	}
 
-	if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
-		return false
-	}
-
-	isSucceed = true
-	loginUserWithUser(user, c)
-	return true
+	return Error(401, "Unauthorized", nil)
 }
 
-func LoginAPIPing(c *m.ReqContext) {
-	if !tryLoginUsingRememberCookie(c) {
-		c.JsonApiErr(401, "Unauthorized", nil)
-		return
-	}
-
-	c.JsonOK("Logged in")
-}
-
-func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
+func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
 	if setting.DisableLoginForm {
 		return Error(401, "Login is disabled", nil)
 	}
@@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
 
 	user := authQuery.User
 
-	loginUserWithUser(user, c)
+	hs.loginUserWithUser(user, c)
 
 	result := map[string]interface{}{
 		"message": "Logged in",
@@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
 	return JSON(200, result)
 }
 
-func loginUserWithUser(user *m.User, c *m.ReqContext) {
+func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
 	if user == nil {
-		log.Error(3, "User login with nil user")
+		hs.log.Error("User login with nil user")
 	}
 
-	c.Resp.Header().Del("Set-Cookie")
-
-	days := 86400 * setting.LogInRememberDays
-	if days > 0 {
-		c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/")
-		c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/")
+	err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
+	if err != nil {
+		hs.log.Error("User auth hook failed", "error", err)
 	}
-
-	c.Session.RegenerateId(c.Context)
-	c.Session.Set(session.SESS_KEY_USERID, user.Id)
 }
 
-func Logout(c *m.ReqContext) {
-	c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
-	c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
-	c.Session.Destory(c.Context)
+func (hs *HTTPServer) Logout(c *m.ReqContext) {
+	hs.AuthTokenService.UserSignedOutHook(c)
+
 	if setting.SignoutRedirectUrl != "" {
 		c.Redirect(setting.SignoutRedirectUrl)
 	} else {
 		c.Redirect(setting.AppSubUrl + "/login")
 	}
 }
+
+func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) {
+	cookie := ctx.GetCookie(cookieName)
+	if cookie == "" {
+		return "", false
+	}
+
+	decoded, err := hex.DecodeString(cookie)
+	if err != nil {
+		return "", false
+	}
+
+	decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey)
+	return string(decryptedError), err == nil
+}
+
+func deleteCookie(ctx *m.ReqContext, cookieName string) {
+	ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
+}
+
+func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error {
+	encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
+	if err != nil {
+		return err
+	}
+
+	http.SetCookie(ctx.Resp, &http.Cookie{
+		Name:     cookieName,
+		MaxAge:   60,
+		Value:    hex.EncodeToString(encryptedError),
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   hs.Cfg.SecurityHTTPSCookies,
+	})
+
+	return nil
+}

+ 49 - 16
pkg/api/login_oauth.go

@@ -3,9 +3,11 @@ package api
 import (
 	"context"
 	"crypto/rand"
+	"crypto/sha256"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/base64"
+	"encoding/hex"
 	"fmt"
 	"io/ioutil"
 	"net/http"
@@ -18,12 +20,14 @@ import (
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/social"
 )
 
-var oauthLogger = log.New("oauth")
+var (
+	oauthLogger          = log.New("oauth")
+	OauthStateCookieName = "oauth_state"
+)
 
 func GenStateString() string {
 	rnd := make([]byte, 32)
@@ -31,7 +35,7 @@ func GenStateString() string {
 	return base64.URLEncoding.EncodeToString(rnd)
 }
 
-func OAuthLogin(ctx *m.ReqContext) {
+func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
 	if setting.OAuthService == nil {
 		ctx.Handle(404, "OAuth not enabled", nil)
 		return
@@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
 	if errorParam != "" {
 		errorDesc := ctx.Query("error_description")
 		oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
-		redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
+		hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
 		return
 	}
 
 	code := ctx.Query("code")
 	if code == "" {
 		state := GenStateString()
-		ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state)
+		hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
+		hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
 		if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
 			ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
 		} else {
@@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
 		return
 	}
 
-	savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string)
-	if !ok {
+	cookieState := ctx.GetCookie(OauthStateCookieName)
+
+	// delete cookie
+	ctx.Resp.Header().Del("Set-Cookie")
+	hs.deleteCookie(ctx.Resp, OauthStateCookieName)
+
+	if cookieState == "" {
 		ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
 		return
 	}
 
-	queryState := ctx.Query("state")
-	if savedState != queryState {
+	queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
+	oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
+	if cookieState != queryState {
 		ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
 		return
 	}
@@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
 	userInfo, err := connect.UserInfo(client, token)
 	if err != nil {
 		if sErr, ok := err.(*social.Error); ok {
-			redirectWithError(ctx, sErr)
+			hs.redirectWithError(ctx, sErr)
 		} else {
 			ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
 		}
@@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) {
 
 	// validate that we got at least an email address
 	if userInfo.Email == "" {
-		redirectWithError(ctx, login.ErrNoEmail)
+		hs.redirectWithError(ctx, login.ErrNoEmail)
 		return
 	}
 
 	// validate that the email is allowed to login to grafana
 	if !connect.IsEmailAllowed(userInfo.Email) {
-		redirectWithError(ctx, login.ErrEmailNotAllowed)
+		hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
 		return
 	}
 
@@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
 		ExternalUser:  extUser,
 		SignupAllowed: connect.IsSignupAllowed(),
 	}
+
 	err = bus.Dispatch(cmd)
 	if err != nil {
-		redirectWithError(ctx, err)
+		hs.redirectWithError(ctx, err)
 		return
 	}
 
 	// login
-	loginUserWithUser(cmd.Result, ctx)
+	hs.loginUserWithUser(cmd.Result, ctx)
 
 	metrics.M_Api_Login_OAuth.Inc()
 
@@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
 	ctx.Redirect(setting.AppSubUrl + "/")
 }
 
-func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
+func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
+	hs.writeCookie(w, name, "", -1)
+}
+
+func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     name,
+		MaxAge:   maxAge,
+		Value:    value,
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   hs.Cfg.SecurityHTTPSCookies,
+	})
+}
+
+func hashStatecode(code, seed string) string {
+	hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
+	return hex.EncodeToString(hashBytes[:])
+}
+
+func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
 	ctx.Logger.Error(err.Error(), v...)
-	ctx.Session.Set("loginError", err.Error())
+	hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
+
 	ctx.Redirect(setting.AppSubUrl + "/login")
 }

+ 2 - 2
pkg/api/org_invite.go

@@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response {
 	})
 }
 
-func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
+func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
 	query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
 
 	if err := bus.Dispatch(&query); err != nil {
@@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
 		return rsp
 	}
 
-	loginUserWithUser(user, c)
+	hs.loginUserWithUser(user, c)
 
 	metrics.M_Api_User_SignUpCompleted.Inc()
 	metrics.M_Api_User_SignUpInvite.Inc()

+ 2 - 2
pkg/api/signup.go

@@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
 	return JSON(200, util.DynMap{"status": "SignUpCreated"})
 }
 
-func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
+func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
 	if !setting.AllowUserSignUp {
 		return Error(401, "User signup is disabled", nil)
 	}
@@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
 		apiResponse["code"] = "redirect-to-select-org"
 	}
 
-	loginUserWithUser(user, c)
+	hs.loginUserWithUser(user, c)
 	metrics.M_Api_User_SignUpCompleted.Inc()
 
 	return JSON(200, apiResponse)

+ 5 - 2
pkg/components/imguploader/imguploader.go

@@ -6,7 +6,6 @@ import (
 	"regexp"
 
 	"github.com/grafana/grafana/pkg/log"
-
 	"github.com/grafana/grafana/pkg/setting"
 )
 
@@ -21,6 +20,10 @@ func (NopImageUploader) Upload(ctx context.Context, path string) (string, error)
 	return "", nil
 }
 
+var (
+	logger = log.New("imguploader")
+)
+
 func NewImageUploader() (ImageUploader, error) {
 
 	switch setting.ImageUploadProvider {
@@ -94,7 +97,7 @@ func NewImageUploader() (ImageUploader, error) {
 	}
 
 	if setting.ImageUploadProvider != "" {
-		log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
+		logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
 	}
 
 	return NopImageUploader{}, nil

+ 2 - 20
pkg/log/log.go

@@ -10,13 +10,11 @@ import (
 	"path/filepath"
 	"strings"
 
-	"gopkg.in/ini.v1"
-
 	"github.com/go-stack/stack"
+	"github.com/grafana/grafana/pkg/util"
 	"github.com/inconshreveable/log15"
 	isatty "github.com/mattn/go-isatty"
-
-	"github.com/grafana/grafana/pkg/util"
+	"gopkg.in/ini.v1"
 )
 
 var Root log15.Logger
@@ -58,10 +56,6 @@ func Debug(format string, v ...interface{}) {
 	Root.Debug(message)
 }
 
-func Debug2(message string, v ...interface{}) {
-	Root.Debug(message, v...)
-}
-
 func Info(format string, v ...interface{}) {
 	var message string
 	if len(v) > 0 {
@@ -73,10 +67,6 @@ func Info(format string, v ...interface{}) {
 	Root.Info(message)
 }
 
-func Info2(message string, v ...interface{}) {
-	Root.Info(message, v...)
-}
-
 func Warn(format string, v ...interface{}) {
 	var message string
 	if len(v) > 0 {
@@ -88,18 +78,10 @@ func Warn(format string, v ...interface{}) {
 	Root.Warn(message)
 }
 
-func Warn2(message string, v ...interface{}) {
-	Root.Warn(message, v...)
-}
-
 func Error(skip int, format string, v ...interface{}) {
 	Root.Error(fmt.Sprintf(format, v...))
 }
 
-func Error2(message string, v ...interface{}) {
-	Root.Error(message, v...)
-}
-
 func Critical(skip int, format string, v ...interface{}) {
 	Root.Crit(fmt.Sprintf(format, v...))
 }

+ 5 - 1
pkg/login/ext_user.go

@@ -11,6 +11,10 @@ func init() {
 	bus.AddHandler("auth", UpsertUser)
 }
 
+var (
+	logger = log.New("login.ext_user")
+)
+
 func UpsertUser(cmd *m.UpsertUserCommand) error {
 	extUser := cmd.ExternalUser
 
@@ -135,7 +139,7 @@ func updateUser(user *m.User, extUser *m.ExternalUserInfo) error {
 		return nil
 	}
 
-	log.Debug2("Syncing user info", "id", user.Id, "update", updateCmd)
+	logger.Debug("Syncing user info", "id", user.Id, "update", updateCmd)
 	return bus.Dispatch(updateCmd)
 }
 

+ 0 - 11
pkg/middleware/auth.go

@@ -7,7 +7,6 @@ import (
 	"gopkg.in/macaron.v1"
 
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
@@ -17,16 +16,6 @@ type AuthOptions struct {
 	ReqSignedIn     bool
 }
 
-func getRequestUserId(c *m.ReqContext) int64 {
-	userID := c.Session.Get(session.SESS_KEY_USERID)
-
-	if userID != nil {
-		return userID.(int64)
-	}
-
-	return 0
-}
-
 func getApiKey(c *m.ReqContext) string {
 	header := c.Req.Header.Get("Authorization")
 	parts := strings.SplitN(header, " ", 2)

+ 19 - 1
pkg/middleware/auth_proxy.go

@@ -16,7 +16,9 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 )
 
-var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
+var (
+	AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
+)
 
 func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 	if !setting.AuthProxyEnabled {
@@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 		return false
 	}
 
+	defer func() {
+		if err := ctx.Session.Release(); err != nil {
+			ctx.Logger.Error("failed to save session data", "error", err)
+		}
+	}()
+
 	query := &m.GetSignedInUserQuery{OrgId: orgID}
 
 	// if this session has already been authenticated by authProxy just load the user
@@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
 	return nil
 }
 
+func getRequestUserId(c *m.ReqContext) int64 {
+	userID := c.Session.Get(session.SESS_KEY_USERID)
+
+	if userID != nil {
+		return userID.(int64)
+	}
+
+	return 0
+}
+
 func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
 	if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
 		return nil

+ 5 - 28
pkg/middleware/middleware.go

@@ -3,15 +3,15 @@ package middleware
 import (
 	"strconv"
 
-	"gopkg.in/macaron.v1"
-
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/apikeygen"
 	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
+	macaron "gopkg.in/macaron.v1"
 )
 
 var (
@@ -21,12 +21,12 @@ var (
 	ReqOrgAdmin     = RoleAuth(m.ROLE_ADMIN)
 )
 
-func GetContextHandler() macaron.Handler {
+func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
 	return func(c *macaron.Context) {
 		ctx := &m.ReqContext{
 			Context:        c,
 			SignedInUser:   &m.SignedInUser{},
-			Session:        session.GetSession(),
+			Session:        session.GetSession(), // should only be used by auth_proxy
 			IsSignedIn:     false,
 			AllowAnonymous: false,
 			SkipCache:      false,
@@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler {
 		case initContextWithApiKey(ctx):
 		case initContextWithBasicAuth(ctx, orgId):
 		case initContextWithAuthProxy(ctx, orgId):
-		case initContextWithUserSessionCookie(ctx, orgId):
+		case ats.InitContextWithToken(ctx, orgId):
 		case initContextWithAnonymousUser(ctx):
 		}
 
@@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
 	return true
 }
 
-func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool {
-	// initialize session
-	if err := ctx.Session.Start(ctx.Context); err != nil {
-		ctx.Logger.Error("Failed to start session", "error", err)
-		return false
-	}
-
-	var userId int64
-	if userId = getRequestUserId(ctx); userId == 0 {
-		return false
-	}
-
-	query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId}
-	if err := bus.Dispatch(&query); err != nil {
-		ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err)
-		return false
-	}
-
-	ctx.SignedInUser = query.Result
-	ctx.IsSignedIn = true
-	return true
-}
-
 func initContextWithApiKey(ctx *m.ReqContext) bool {
 	var keyString string
 	if keyString = getApiKey(ctx); keyString == "" {

+ 48 - 30
pkg/middleware/middleware_test.go

@@ -7,7 +7,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	ms "github.com/go-macaron/session"
+	msession "github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/session"
@@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
 			So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
 		})
 
-		middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
-			sc.fakeReq("GET", "/").exec()
-			So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
-		})
-
 		middlewareScenario("Invalid api key", func(sc *scenarioContext) {
 			sc.apiKey = "invalid_key_test"
 			sc.fakeReq("GET", "/").exec()
@@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 		})
 
-		middlewareScenario("UserId in session", func(sc *scenarioContext) {
-
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
-
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
-				return nil
-			})
+		middlewareScenario("Auth token service", func(sc *scenarioContext) {
+			var wasCalled bool
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				wasCalled = true
+				return false
+			}
 
 			sc.fakeReq("GET", "/").exec()
 
-			Convey("should init context with user info", func() {
-				So(sc.context.IsSignedIn, ShouldBeTrue)
-				So(sc.context.UserId, ShouldEqual, 12)
+			Convey("should call middleware", func() {
+				So(wasCalled, ShouldBeTrue)
 			})
 		})
 
@@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
 				return nil
 			})
 
+			setting.SessionOptions = msession.Options{}
 			sc.fakeReq("GET", "/")
 			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
 			sc.exec()
@@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 		defer bus.ClearBusHandlers()
 
 		sc := &scenarioContext{}
+
 		viewsPath, _ := filepath.Abs("../../public/views")
 
 		sc.m = macaron.New()
@@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 		}))
 
-		sc.m.Use(GetContextHandler())
+		session.Init(&msession.Options{}, 0)
+		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		session.StartSessionGC = func() {}
-		sc.m.Use(Sessioner(&ms.Options{}, 0))
+		setting.SessionOptions = msession.Options{}
+
 		sc.m.Use(OrgRedirect())
 		sc.m.Use(AddDefaultResponseHeaders())
 
@@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 }
 
 type scenarioContext struct {
-	m              *macaron.Macaron
-	context        *m.ReqContext
-	resp           *httptest.ResponseRecorder
-	apiKey         string
-	authHeader     string
-	respJson       map[string]interface{}
-	handlerFunc    handlerFunc
-	defaultHandler macaron.Handler
-	url            string
+	m                    *macaron.Macaron
+	context              *m.ReqContext
+	resp                 *httptest.ResponseRecorder
+	apiKey               string
+	authHeader           string
+	respJson             map[string]interface{}
+	handlerFunc          handlerFunc
+	defaultHandler       macaron.Handler
+	url                  string
+	userAuthTokenService *fakeUserAuthTokenService
 
 	req *http.Request
 }
@@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
 
 type scenarioFunc func(c *scenarioContext)
 type handlerFunc func(c *m.ReqContext)
+
+type fakeUserAuthTokenService struct {
+	initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+}
+
+func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
+	return &fakeUserAuthTokenService{
+		initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
+			return false
+		},
+	}
+}
+
+func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
+	return s.initContextWithTokenProvider(ctx, orgID)
+}
+
+func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
+	return nil
+}
+
+func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}

+ 0 - 1
pkg/middleware/org_redirect.go

@@ -9,7 +9,6 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
-
 	"gopkg.in/macaron.v1"
 )
 

+ 11 - 13
pkg/middleware/org_redirect_test.go

@@ -7,7 +7,6 @@ import (
 
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
@@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 
 	Convey("Can redirect to correct org", t, func() {
 		middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
-
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 				return nil
 			})
 
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
-				return nil
-			})
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
+				ctx.IsSignedIn = true
+				return true
+			}
 
 			sc.m.Get("/", sc.defaultHandler)
 			sc.fakeReq("GET", "/?orgId=3").exec()
@@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 		})
 
 		middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
-
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 				return fmt.Errorf("")
 			})
 
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
+				ctx.IsSignedIn = true
+				return true
+			}
+
 			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
 				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
 				return nil

+ 5 - 8
pkg/middleware/quota_test.go

@@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
 		})
 
 		middlewareScenario("with user logged in", func(sc *scenarioContext) {
-			// log us in, so we have a user_id and org_id in the context
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
+				ctx.IsSignedIn = true
+				return true
+			}
 
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
-				return nil
-			})
 			bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
 				query.Result = &m.GlobalQuotaDTO{
 					Target: query.Target,

+ 3 - 4
pkg/middleware/recovery_test.go

@@ -4,13 +4,12 @@ import (
 	"path/filepath"
 	"testing"
 
-	ms "github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	. "github.com/smartystreets/goconvey/convey"
-	"gopkg.in/macaron.v1"
+	macaron "gopkg.in/macaron.v1"
 )
 
 func TestRecoveryMiddleware(t *testing.T) {
@@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 		}))
 
-		sc.m.Use(GetContextHandler())
+		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		session.StartSessionGC = func() {}
-		sc.m.Use(Sessioner(&ms.Options{}, 0))
 		sc.m.Use(OrgRedirect())
 		sc.m.Use(AddDefaultResponseHeaders())
 

+ 0 - 21
pkg/middleware/session.go

@@ -1,21 +0,0 @@
-package middleware
-
-import (
-	ms "github.com/go-macaron/session"
-	"gopkg.in/macaron.v1"
-
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
-)
-
-func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler {
-	session.Init(options, sessionConnMaxLifetime)
-
-	return func(ctx *m.ReqContext) {
-		ctx.Next()
-
-		if err := ctx.Session.Release(); err != nil {
-			panic("session(release): " + err.Error())
-		}
-	}
-}

+ 3 - 3
pkg/models/context.go

@@ -3,18 +3,18 @@ package models
 import (
 	"strings"
 
-	"github.com/prometheus/client_golang/prometheus"
-	"gopkg.in/macaron.v1"
-
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/prometheus/client_golang/prometheus"
+	"gopkg.in/macaron.v1"
 )
 
 type ReqContext struct {
 	*macaron.Context
 	*SignedInUser
 
+	// This should only be used by the auth_proxy
 	Session session.SessionStore
 
 	IsSignedIn     bool

+ 1 - 1
pkg/services/alerting/notifiers/telegram.go

@@ -130,7 +130,7 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
 	defer func() {
 		err := imageFile.Close()
 		if err != nil {
-			log.Error2("Could not close Telegram inline image.", "err", err)
+			this.log.Error("Could not close Telegram inline image.", "err", err)
 		}
 	}()
 

+ 5 - 2
pkg/services/alerting/test_notification.go

@@ -18,9 +18,12 @@ type NotificationTestCommand struct {
 	Settings *simplejson.Json
 }
 
+var (
+	logger = log.New("alerting.testnotification")
+)
+
 func init() {
 	bus.AddHandler("alerting", handleNotificationTestCommand)
-
 }
 
 func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
@@ -35,7 +38,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
 	notifiers, err := InitNotifier(model)
 
 	if err != nil {
-		log.Error2("Failed to create notifier", "error", err.Error())
+		logger.Error("Failed to create notifier", "error", err.Error())
 		return err
 	}
 

+ 266 - 0
pkg/services/auth/auth_token.go

@@ -0,0 +1,266 @@
+package auth
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/infra/serverlock"
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+func init() {
+	registry.RegisterService(&UserAuthTokenServiceImpl{})
+}
+
+var (
+	getTime          = time.Now
+	UrgentRotateTime = 1 * time.Minute
+	oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
+)
+
+// UserAuthTokenService are used for generating and validating user auth tokens
+type UserAuthTokenService interface {
+	InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
+	UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
+	UserSignedOutHook(c *models.ReqContext)
+}
+
+type UserAuthTokenServiceImpl struct {
+	SQLStore          *sqlstore.SqlStore            `inject:""`
+	ServerLockService *serverlock.ServerLockService `inject:""`
+	Cfg               *setting.Cfg                  `inject:""`
+	log               log.Logger
+}
+
+// Init this service
+func (s *UserAuthTokenServiceImpl) Init() error {
+	s.log = log.New("auth")
+	return nil
+}
+
+func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool {
+	//auth User
+	unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName)
+	if unhashedToken == "" {
+		return false
+	}
+
+	userToken, err := s.LookupToken(unhashedToken)
+	if err != nil {
+		ctx.Logger.Info("failed to look up user based on cookie", "error", err)
+		return false
+	}
+
+	query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID}
+	if err := bus.Dispatch(&query); err != nil {
+		ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err)
+		return false
+	}
+
+	ctx.SignedInUser = query.Result
+	ctx.IsSignedIn = true
+
+	//rotate session token if needed.
+	rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent())
+	if err != nil {
+		ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id)
+		return true
+	}
+
+	if rotated {
+		s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds)
+	}
+
+	return true
+}
+
+func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
+	if setting.Env == setting.DEV {
+		ctx.Logger.Info("new token", "unhashed token", value)
+	}
+
+	ctx.Resp.Header().Del("Set-Cookie")
+	cookie := http.Cookie{
+		Name:     s.Cfg.LoginCookieName,
+		Value:    url.QueryEscape(value),
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   s.Cfg.SecurityHTTPSCookies,
+		MaxAge:   maxAge,
+	}
+
+	http.SetCookie(ctx.Resp, &cookie)
+}
+
+func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error {
+	userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
+	if err != nil {
+		return err
+	}
+
+	s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds)
+	return nil
+}
+
+func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) {
+	s.writeSessionCookie(c, "", -1)
+}
+
+func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
+	clientIP = util.ParseIPAddress(clientIP)
+	token, err := util.RandomHex(16)
+	if err != nil {
+		return nil, err
+	}
+
+	hashedToken := hashToken(token)
+
+	now := getTime().Unix()
+
+	userToken := userAuthToken{
+		UserId:        userId,
+		AuthToken:     hashedToken,
+		PrevAuthToken: hashedToken,
+		ClientIp:      clientIP,
+		UserAgent:     userAgent,
+		RotatedAt:     now,
+		CreatedAt:     now,
+		UpdatedAt:     now,
+		SeenAt:        0,
+		AuthTokenSeen: false,
+	}
+	_, err = s.SQLStore.NewSession().Insert(&userToken)
+	if err != nil {
+		return nil, err
+	}
+
+	userToken.UnhashedToken = token
+
+	return &userToken, nil
+}
+
+func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
+	hashedToken := hashToken(unhashedToken)
+	if setting.Env == setting.DEV {
+		s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
+	}
+
+	expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
+
+	var userToken userAuthToken
+	exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
+	if err != nil {
+		return nil, err
+	}
+
+	if !exists {
+		return nil, ErrAuthTokenNotFound
+	}
+
+	if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen {
+		userTokenCopy := userToken
+		userTokenCopy.AuthTokenSeen = false
+		expireBefore := getTime().Add(-UrgentRotateTime).Unix()
+		affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy)
+		if err != nil {
+			return nil, err
+		}
+
+		if affectedRows == 0 {
+			s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		} else {
+			s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		}
+	}
+
+	if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken {
+		userTokenCopy := userToken
+		userTokenCopy.AuthTokenSeen = true
+		userTokenCopy.SeenAt = getTime().Unix()
+		affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy)
+		if err != nil {
+			return nil, err
+		}
+
+		if affectedRows == 1 {
+			userToken = userTokenCopy
+		}
+
+		if affectedRows == 0 {
+			s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		} else {
+			s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		}
+	}
+
+	userToken.UnhashedToken = unhashedToken
+
+	return &userToken, nil
+}
+
+func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
+	if token == nil {
+		return false, nil
+	}
+
+	now := getTime()
+
+	needsRotation := false
+	rotatedAt := time.Unix(token.RotatedAt, 0)
+	if token.AuthTokenSeen {
+		needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute))
+	} else {
+		needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
+	}
+
+	if !needsRotation {
+		return false, nil
+	}
+
+	s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id)
+
+	clientIP = util.ParseIPAddress(clientIP)
+	newToken, _ := util.RandomHex(16)
+	hashedToken := hashToken(newToken)
+
+	// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
+	sql := `
+		UPDATE user_auth_token
+		SET
+			seen_at = 0,
+			user_agent = ?,
+			client_ip = ?,
+			prev_auth_token = case when auth_token_seen = ? then auth_token else prev_auth_token end,
+			auth_token = ?,
+			auth_token_seen = ?,
+			rotated_at = ?
+		WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)`
+
+	res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
+	if err != nil {
+		return false, err
+	}
+
+	affected, _ := res.RowsAffected()
+	s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId)
+	if affected > 0 {
+		token.UnhashedToken = newToken
+		return true, nil
+	}
+
+	return false, nil
+}
+
+func hashToken(token string) string {
+	hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
+	return hex.EncodeToString(hashBytes[:])
+}

+ 339 - 0
pkg/services/auth/auth_token_test.go

@@ -0,0 +1,339 @@
+package auth
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/setting"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthToken(t *testing.T) {
+	Convey("Test user auth token", t, func() {
+		ctx := createTestContext(t)
+		userAuthTokenService := ctx.tokenService
+		userID := int64(10)
+
+		t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
+		getTime = func() time.Time {
+			return t
+		}
+
+		Convey("When creating token", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+			So(token.AuthTokenSeen, ShouldBeFalse)
+
+			Convey("When lookup unhashed token should return user auth token", func() {
+				LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+				So(err, ShouldBeNil)
+				So(LookupToken, ShouldNotBeNil)
+				So(LookupToken.UserId, ShouldEqual, userID)
+				So(LookupToken.AuthTokenSeen, ShouldBeTrue)
+
+				storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
+				So(err, ShouldBeNil)
+				So(storedAuthToken, ShouldNotBeNil)
+				So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
+			})
+
+			Convey("When lookup hashed token should return user auth token not found error", func() {
+				LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken)
+				So(err, ShouldEqual, ErrAuthTokenNotFound)
+				So(LookupToken, ShouldBeNil)
+			})
+		})
+
+		Convey("expires correctly", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(time.Hour)
+			}
+
+			refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+
+			stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(stillGood, ShouldNotBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(24 * 7 * time.Hour)
+			}
+			notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldEqual, ErrAuthTokenNotFound)
+			So(notGood, ShouldBeNil)
+		})
+
+		Convey("can properly rotate tokens", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			prevToken := token.AuthToken
+			unhashedPrev := token.UnhashedToken
+
+			refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeFalse)
+
+			updated, err := ctx.markAuthTokenAsSeen(token.Id)
+			So(err, ShouldBeNil)
+			So(updated, ShouldBeTrue)
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(time.Hour)
+			}
+
+			refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			unhashedToken := token.UnhashedToken
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+			token.UnhashedToken = unhashedToken
+
+			So(token.RotatedAt, ShouldEqual, getTime().Unix())
+			So(token.ClientIp, ShouldEqual, "192.168.10.12")
+			So(token.UserAgent, ShouldEqual, "a new user agent")
+			So(token.AuthTokenSeen, ShouldBeFalse)
+			So(token.SeenAt, ShouldEqual, 0)
+			So(token.PrevAuthToken, ShouldEqual, prevToken)
+
+			// ability to auth using an old token
+
+			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+			So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
+
+			lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.Id, ShouldEqual, token.Id)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+
+			getTime = func() time.Time {
+				return t.Add(time.Hour + (2 * time.Minute))
+			}
+
+			lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+
+			lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeFalse)
+
+			refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+			So(token.SeenAt, ShouldEqual, 0)
+		})
+
+		Convey("keeps prev token valid for 1 minute after it is confirmed", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(10 * time.Minute)
+			}
+
+			prevToken := token.UnhashedToken
+			refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			getTime = func() time.Time {
+				return t.Add(20 * time.Minute)
+			}
+
+			current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(current, ShouldNotBeNil)
+
+			prev, err := userAuthTokenService.LookupToken(prevToken)
+			So(err, ShouldBeNil)
+			So(prev, ShouldNotBeNil)
+		})
+
+		Convey("will not mark token unseen when prev and current are the same", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+
+			lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+
+			lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+		})
+
+		Convey("Rotate token", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			prevToken := token.AuthToken
+
+			Convey("Should rotate current token and previous token when auth token seen", func() {
+				updated, err := ctx.markAuthTokenAsSeen(token.Id)
+				So(err, ShouldBeNil)
+				So(updated, ShouldBeTrue)
+
+				getTime = func() time.Time {
+					return t.Add(10 * time.Minute)
+				}
+
+				refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				So(err, ShouldBeNil)
+				So(refreshed, ShouldBeTrue)
+
+				storedToken, err := ctx.getAuthTokenByID(token.Id)
+				So(err, ShouldBeNil)
+				So(storedToken, ShouldNotBeNil)
+				So(storedToken.AuthTokenSeen, ShouldBeFalse)
+				So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+				So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+
+				prevToken = storedToken.AuthToken
+
+				updated, err = ctx.markAuthTokenAsSeen(token.Id)
+				So(err, ShouldBeNil)
+				So(updated, ShouldBeTrue)
+
+				getTime = func() time.Time {
+					return t.Add(20 * time.Minute)
+				}
+
+				refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				So(err, ShouldBeNil)
+				So(refreshed, ShouldBeTrue)
+
+				storedToken, err = ctx.getAuthTokenByID(token.Id)
+				So(err, ShouldBeNil)
+				So(storedToken, ShouldNotBeNil)
+				So(storedToken.AuthTokenSeen, ShouldBeFalse)
+				So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+				So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+			})
+
+			Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
+				token.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
+
+				getTime = func() time.Time {
+					return t.Add(2 * time.Minute)
+				}
+
+				refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				So(err, ShouldBeNil)
+				So(refreshed, ShouldBeTrue)
+
+				storedToken, err := ctx.getAuthTokenByID(token.Id)
+				So(err, ShouldBeNil)
+				So(storedToken, ShouldNotBeNil)
+				So(storedToken.AuthTokenSeen, ShouldBeFalse)
+				So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+				So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+			})
+		})
+
+		Reset(func() {
+			getTime = time.Now
+		})
+	})
+}
+
+func createTestContext(t *testing.T) *testContext {
+	t.Helper()
+
+	sqlstore := sqlstore.InitTestDB(t)
+	tokenService := &UserAuthTokenServiceImpl{
+		SQLStore: sqlstore,
+		Cfg: &setting.Cfg{
+			LoginCookieName:                   "grafana_session",
+			LoginCookieMaxDays:                7,
+			LoginDeleteExpiredTokensAfterDays: 30,
+			LoginCookieRotation:               10,
+		},
+		log: log.New("test-logger"),
+	}
+
+	UrgentRotateTime = time.Minute
+
+	return &testContext{
+		sqlstore:     sqlstore,
+		tokenService: tokenService,
+	}
+}
+
+type testContext struct {
+	sqlstore     *sqlstore.SqlStore
+	tokenService *UserAuthTokenServiceImpl
+}
+
+func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
+	sess := c.sqlstore.NewSession()
+	var t userAuthToken
+	found, err := sess.ID(id).Get(&t)
+	if err != nil || !found {
+		return nil, err
+	}
+
+	return &t, nil
+}
+
+func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
+	sess := c.sqlstore.NewSession()
+	res, err := sess.Exec("UPDATE user_auth_token SET auth_token_seen = ? WHERE id = ?", c.sqlstore.Dialect.BooleanStr(true), id)
+	if err != nil {
+		return false, err
+	}
+
+	rowsAffected, err := res.RowsAffected()
+	if err != nil {
+		return false, err
+	}
+	return rowsAffected == 1, nil
+}

+ 25 - 0
pkg/services/auth/model.go

@@ -0,0 +1,25 @@
+package auth
+
+import (
+	"errors"
+)
+
+// Typed errors
+var (
+	ErrAuthTokenNotFound = errors.New("User auth token not found")
+)
+
+type userAuthToken struct {
+	Id            int64
+	UserId        int64
+	AuthToken     string
+	PrevAuthToken string
+	UserAgent     string
+	ClientIp      string
+	AuthTokenSeen bool
+	SeenAt        int64
+	RotatedAt     int64
+	CreatedAt     int64
+	UpdatedAt     int64
+	UnhashedToken string `xorm:"-"`
+}

+ 38 - 0
pkg/services/auth/session_cleanup.go

@@ -0,0 +1,38 @@
+package auth
+
+import (
+	"context"
+	"time"
+)
+
+func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error {
+	ticker := time.NewTicker(time.Hour * 12)
+	deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays)
+
+	for {
+		select {
+		case <-ticker.C:
+			srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() {
+				srv.deleteOldSession(deleteSessionAfter)
+			})
+
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}
+
+func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) {
+	sql := `DELETE from user_auth_token WHERE rotated_at < ?`
+
+	deleteBefore := getTime().Add(-deleteSessionAfter)
+	res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix())
+	if err != nil {
+		return 0, err
+	}
+
+	affected, err := res.RowsAffected()
+	srv.log.Info("deleted old sessions", "count", affected)
+
+	return affected, err
+}

+ 36 - 0
pkg/services/auth/session_cleanup_test.go

@@ -0,0 +1,36 @@
+package auth
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthTokenCleanup(t *testing.T) {
+
+	Convey("Test user auth token cleanup", t, func() {
+		ctx := createTestContext(t)
+
+		insertToken := func(token string, prev string, rotatedAt int64) {
+			ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
+			_, err := ctx.sqlstore.NewSession().Insert(&ut)
+			So(err, ShouldBeNil)
+		}
+
+		// insert three old tokens that should be deleted
+		for i := 0; i < 3; i++ {
+			insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i))
+		}
+
+		// insert three active tokens that should not be deleted
+		for i := 0; i < 3; i++ {
+			insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix())
+		}
+
+		affected, err := ctx.tokenService.deleteOldSession(time.Hour)
+		So(err, ShouldBeNil)
+		So(affected, ShouldEqual, 3)
+	})
+}

+ 1 - 5
pkg/services/dashboards/dashboard_service.go

@@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand,
 		User:      dto.User,
 	}
 
-	if err := bus.Dispatch(&alertCmd); err != nil {
-		return err
-	}
-
-	return nil
+	return bus.Dispatch(&alertCmd)
 }
 
 func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {

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

@@ -1,6 +1,7 @@
 package dashboards
 
 import (
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -69,7 +70,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
 
 		parsedDashboards, err := cr.parseConfigs(file)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("could not parse provisioning config file: %s error: %v", file.Name(), err)
 		}
 
 		if len(parsedDashboards) > 0 {

+ 2 - 1
pkg/services/provisioning/dashboards/file_reader.go

@@ -118,6 +118,7 @@ func (fr *fileReader) startWalkingDisk() error {
 
 	return nil
 }
+
 func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) {
 	if fr.Cfg.DisableDeletion {
 		return
@@ -180,7 +181,7 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil
 		dash.Dashboard.SetId(provisionedData.DashboardId)
 	}
 
-	fr.log.Debug("saving new dashboard", "file", path)
+	fr.log.Debug("saving new dashboard", "provisoner", fr.Cfg.Name, "file", path, "folderId", dash.Dashboard.FolderId)
 	dp := &models.DashboardProvisioning{
 		ExternalId: path,
 		Name:       fr.Cfg.Name,

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

@@ -166,6 +166,36 @@ func TestDashboardFileReader(t *testing.T) {
 				_, err := NewDashboardFileReader(cfg, logger)
 				So(err, ShouldBeNil)
 			})
+
+			Convey("Two dashboard providers should be able to provisioned the same dashboard without uid", func() {
+				cfg1 := &DashboardsAsConfig{Name: "1", Type: "file", OrgId: 1, Folder: "f1", Options: map[string]interface{}{"path": containingId}}
+				cfg2 := &DashboardsAsConfig{Name: "2", Type: "file", OrgId: 1, Folder: "f2", Options: map[string]interface{}{"path": containingId}}
+
+				reader1, err := NewDashboardFileReader(cfg1, logger)
+				So(err, ShouldBeNil)
+
+				err = reader1.startWalkingDisk()
+				So(err, ShouldBeNil)
+
+				reader2, err := NewDashboardFileReader(cfg2, logger)
+				So(err, ShouldBeNil)
+
+				err = reader2.startWalkingDisk()
+				So(err, ShouldBeNil)
+
+				var folderCount int
+				var dashCount int
+				for _, o := range fakeService.inserted {
+					if o.Dashboard.IsFolder {
+						folderCount++
+					} else {
+						dashCount++
+					}
+				}
+
+				So(folderCount, ShouldEqual, 2)
+				So(dashCount, ShouldEqual, 2)
+			})
 		})
 
 		Convey("Should not create new folder if folder name is missing", func() {
@@ -256,7 +286,9 @@ func (ffi FakeFileInfo) Sys() interface{} {
 }
 
 func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
-	mock := fakeDashboardProvisioningService{}
+	mock := fakeDashboardProvisioningService{
+		provisioned: map[string][]*models.DashboardProvisioning{},
+	}
 	dashboards.NewProvisioningService = func() dashboards.DashboardProvisioningService {
 		return &mock
 	}
@@ -265,17 +297,26 @@ func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
 
 type fakeDashboardProvisioningService struct {
 	inserted     []*dashboards.SaveDashboardDTO
-	provisioned  []*models.DashboardProvisioning
+	provisioned  map[string][]*models.DashboardProvisioning
 	getDashboard []*models.Dashboard
 }
 
 func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
-	return s.provisioned, nil
+	if _, ok := s.provisioned[name]; !ok {
+		s.provisioned[name] = []*models.DashboardProvisioning{}
+	}
+
+	return s.provisioned[name], nil
 }
 
 func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
 	s.inserted = append(s.inserted, dto)
-	s.provisioned = append(s.provisioned, provisioning)
+
+	if _, ok := s.provisioned[provisioning.Name]; !ok {
+		s.provisioned[provisioning.Name] = []*models.DashboardProvisioning{}
+	}
+
+	s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], provisioning)
 	return dto.Dashboard, nil
 }
 

+ 0 - 2
pkg/services/session/session.go

@@ -14,8 +14,6 @@ import (
 
 const (
 	SESS_KEY_USERID       = "uid"
-	SESS_KEY_OAUTH_STATE  = "state"
-	SESS_KEY_APIKEY       = "apikey_id" // used for render requests with api keys
 	SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
 )
 

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

@@ -51,7 +51,7 @@ func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error
 func saveProvionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error {
 	result := &models.DashboardProvisioning{}
 
-	exist, err := sess.Where("dashboard_id=?", dashboard.Id).Get(result)
+	exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, cmd.Name).Get(result)
 	if err != nil {
 		return err
 	}

Деякі файли не було показано, через те що забагато файлів було змінено