瀏覽代碼

Merge branch 'master' into bar-gauge-refactoring

Torkel Ödegaard 6 年之前
父節點
當前提交
6be416dd93
共有 100 個文件被更改,包括 3087 次插入640 次删除
  1. 17 0
      .circleci/config.yml
  2. 4 0
      CHANGELOG.md
  3. 14 0
      conf/defaults.ini
  4. 14 0
      conf/sample.ini
  5. 1 1
      devenv/docker/blocks/redis/docker-compose.yaml
  6. 2 0
      docs/sources/enterprise/index.md
  7. 102 0
      docs/sources/http_api/admin.md
  8. 72 0
      docs/sources/http_api/user.md
  9. 29 1
      docs/sources/installation/configuration.md
  10. 4 6
      packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx
  11. 24 44
      packages/grafana-ui/src/components/BarGauge/BarGauge.tsx
  12. 5 2
      packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap
  13. 4 88
      packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
  14. 8 56
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  15. 5 7
      packages/grafana-ui/src/components/Input/Input.test.tsx
  16. 14 27
      packages/grafana-ui/src/components/Input/Input.tsx
  17. 0 0
      packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap
  18. 1 1
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  19. 1 0
      packages/grafana-ui/src/components/index.ts
  20. 1 0
      packages/grafana-ui/src/types/index.ts
  21. 0 0
      packages/grafana-ui/src/types/input.ts
  22. 2 2
      packages/grafana-ui/src/types/panel.ts
  23. 1 0
      packages/grafana-ui/src/types/plugin.ts
  24. 157 0
      packages/grafana-ui/src/utils/displayValue.test.ts
  25. 145 0
      packages/grafana-ui/src/utils/displayValue.ts
  26. 2 0
      packages/grafana-ui/src/utils/index.ts
  27. 24 0
      packages/grafana-ui/src/utils/validate.ts
  28. 23 0
      pkg/api/admin_users.go
  29. 138 0
      pkg/api/admin_users_test.go
  30. 7 0
      pkg/api/api.go
  31. 3 3
      pkg/api/app_routes.go
  32. 9 7
      pkg/api/common_test.go
  33. 1 1
      pkg/api/dataproxy.go
  34. 12 0
      pkg/api/dtos/user_token.go
  35. 7 1
      pkg/api/pluginproxy/ds_proxy.go
  36. 73 14
      pkg/api/pluginproxy/ds_proxy_test.go
  37. 6 1
      pkg/api/pluginproxy/pluginproxy.go
  38. 56 0
      pkg/api/pluginproxy/pluginproxy_test.go
  39. 110 0
      pkg/api/user_token.go
  40. 294 0
      pkg/api/user_token_test.go
  41. 1 0
      pkg/cmd/grafana-server/server.go
  42. 126 0
      pkg/infra/remotecache/database_storage.go
  43. 56 0
      pkg/infra/remotecache/database_storage_test.go
  44. 71 0
      pkg/infra/remotecache/memcached_storage.go
  45. 15 0
      pkg/infra/remotecache/memcached_storage_integration_test.go
  46. 62 0
      pkg/infra/remotecache/redis_storage.go
  47. 16 0
      pkg/infra/remotecache/redis_storage_integration_test.go
  48. 133 0
      pkg/infra/remotecache/remotecache.go
  49. 93 0
      pkg/infra/remotecache/remotecache_test.go
  50. 7 60
      pkg/middleware/middleware_test.go
  51. 2 2
      pkg/middleware/org_redirect_test.go
  52. 3 2
      pkg/middleware/quota_test.go
  53. 2 1
      pkg/middleware/recovery_test.go
  54. 10 1
      pkg/models/user_token.go
  55. 51 0
      pkg/services/auth/auth_token.go
  56. 41 0
      pkg/services/auth/auth_token_test.go
  57. 81 0
      pkg/services/auth/testing.go
  58. 22 0
      pkg/services/sqlstore/migrations/cache_data_mig.go
  59. 1 0
      pkg/services/sqlstore/migrations/migrations.go
  60. 18 0
      pkg/setting/setting.go
  61. 4 1
      pkg/tsdb/mssql/mssql.go
  62. 4 1
      pkg/tsdb/mysql/mysql.go
  63. 4 1
      pkg/tsdb/postgres/postgres.go
  64. 69 1
      pkg/tsdb/testdata/scenarios.go
  65. 94 0
      pkg/tsdb/testdata/scenarios_test.go
  66. 48 38
      public/app/app.ts
  67. 0 1
      public/app/core/components/Form/index.ts
  68. 48 20
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  69. 1 1
      public/app/core/config.ts
  70. 89 55
      public/app/core/specs/manage_dashboards.test.ts
  71. 5 8
      public/app/core/utils/explore.ts
  72. 30 0
      public/app/core/utils/query.test.ts
  73. 12 0
      public/app/core/utils/query.ts
  74. 0 16
      public/app/core/utils/validate.ts
  75. 10 7
      public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
  76. 47 14
      public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
  77. 2 7
      public/app/features/dashboard/panel_editor/QueryOptions.tsx
  78. 13 2
      public/app/features/dashboard/state/PanelModel.test.ts
  79. 7 14
      public/app/features/dashboard/state/PanelModel.ts
  80. 3 1
      public/app/features/explore/LogLabelStats.tsx
  81. 6 2
      public/app/features/explore/Table.tsx
  82. 11 9
      public/app/features/explore/state/actions.ts
  83. 5 3
      public/app/features/explore/state/reducers.ts
  84. 2 0
      public/app/features/plugins/built_in_plugins.ts
  85. 22 31
      public/app/plugins/panel/bargauge/BarGaugePanel.tsx
  86. 2 2
      public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx
  87. 2 10
      public/app/plugins/panel/bargauge/module.tsx
  88. 9 13
      public/app/plugins/panel/bargauge/types.ts
  89. 23 29
      public/app/plugins/panel/gauge/GaugePanel.tsx
  90. 3 2
      public/app/plugins/panel/gauge/GaugePanelEditor.tsx
  91. 2 10
      public/app/plugins/panel/gauge/module.tsx
  92. 4 13
      public/app/plugins/panel/gauge/types.ts
  93. 48 0
      public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx
  94. 9 0
      public/app/plugins/panel/singlestat2/README.md
  95. 48 0
      public/app/plugins/panel/singlestat2/SingleStatEditor.tsx
  96. 66 0
      public/app/plugins/panel/singlestat2/SingleStatPanel.tsx
  97. 0 0
      public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx
  98. 83 0
      public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg
  99. 29 0
      public/app/plugins/panel/singlestat2/module.tsx
  100. 20 0
      public/app/plugins/panel/singlestat2/plugin.json

+ 17 - 0
.circleci/config.yml

@@ -56,6 +56,20 @@ jobs:
             name: postgres integration tests
             command: './scripts/circle-test-postgres.sh'
 
+  cache-server-test:
+    docker:
+      - image: circleci/golang:1.11.5
+      - image: circleci/redis:4-alpine
+      - image: memcached
+    working_directory: /go/src/github.com/grafana/grafana
+    steps:
+        - checkout
+        - run: dockerize -wait tcp://127.0.0.1:11211 -timeout 120s
+        - run: dockerize -wait tcp://127.0.0.1:6379 -timeout 120s
+        - run:
+            name: cache server tests
+            command: './scripts/circle-test-cache-servers.sh'
+
   codespell:
     docker:
       - image: circleci/python
@@ -545,6 +559,8 @@ workflows:
             filters: *filter-not-release-or-master
         - postgres-integration-test:
             filters: *filter-not-release-or-master
+        - cache-server-test:
+            filters: *filter-not-release-or-master
         - grafana-docker-pr:
             requires:
               - build
@@ -554,4 +570,5 @@ workflows:
               - gometalinter
               - mysql-integration-test
               - postgres-integration-test
+              - cache-server-test
             filters: *filter-not-release-or-master

+ 4 - 0
CHANGELOG.md

@@ -8,6 +8,10 @@
 * **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683)
 * **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683)
 * **VictorOps**:  Adds more information to the victor ops notifiers [#15744](https://github.com/grafana/grafana/issues/15744), thx [@zhulongcheng](https://github.com/zhulongcheng)
+* **Cache**: Adds support for using out of proc caching in the backend [#10816](https://github.com/grafana/grafana/issues/10816)
+* **Dataproxy**: Make it possible to add user details to requests sent to the dataproxy [#6359](https://github.com/grafana/grafana/issues/6359) and [#15931](https://github.com/grafana/grafana/issues/15931)
+* **Auth**: Support listing and revoking auth tokens via API [#15836](https://github.com/grafana/grafana/issues/15836)
+* **Datasource**: Only log connection string in dev environment [#16001](https://github.com/grafana/grafana/issues/16001)
 
 ### Bug Fixes
 * **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506)

+ 14 - 0
conf/defaults.ini

@@ -106,6 +106,17 @@ path = grafana.db
 # For "sqlite3" only. cache mode setting used for connecting to the database
 cache_mode = private
 
+#################################### Cache server #############################
+[remote_cache]
+# Either "redis", "memcached" or "database" default is "database"
+type = database
+
+# cache connectionstring options
+# database: will use Grafana primary database.
+# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
+# memcache: 127.0.0.1:11211
+connstr =
+
 #################################### Session #############################
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -146,6 +157,9 @@ logging = false
 # How long the data proxy should wait before timing out default is 30 (seconds)
 timeout = 30
 
+# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+send_user_header = false
+
 #################################### Analytics ###########################
 [analytics]
 # Server reporting, sends usage counters to stats.grafana.org every 24 hours.

+ 14 - 0
conf/sample.ini

@@ -102,6 +102,17 @@ log_queries =
 # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
 ;cache_mode = private
 
+#################################### Cache server #############################
+[remote_cache]
+# Either "redis", "memcached" or "database" default is "database"
+;type = database
+
+# cache connectionstring options
+# database: will use Grafana primary database.
+# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
+# memcache: 127.0.0.1:11211
+;connstr =
+
 #################################### Session ####################################
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -133,6 +144,9 @@ log_queries =
 # How long the data proxy should wait before timing out default is 30 (seconds)
 ;timeout = 30
 
+# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+;send_user_header = false
+
 #################################### Analytics ####################################
 [analytics]
 # Server reporting, sends usage counters to stats.grafana.org every 24 hours.

+ 1 - 1
devenv/docker/blocks/redis/docker-compose.yaml

@@ -1,4 +1,4 @@
-  memcached:
+  redis:
     image: redis:latest
     ports:
       - "6379:6379"

+ 2 - 0
docs/sources/enterprise/index.md

@@ -38,6 +38,8 @@ With a Grafana Enterprise license you will get access to premium plugins, includ
 * [DataDog](https://grafana.com/plugins/grafana-datadog-datasource)
 * [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource)
 * [New Relic](https://grafana.com/plugins/grafana-newrelic-datasource)
+* [Amazon Timestream](https://grafana.com/plugins/grafana-timestream-datasource)
+* [Oracle Database](https://grafana.com/plugins/grafana-oracle-datasource)
 
 ## Try Grafana Enterprise
 

+ 102 - 0
docs/sources/http_api/admin.md

@@ -341,3 +341,105 @@ Content-Type: application/json
 
 {"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100}
 ```
+
+## Auth tokens for User
+
+`GET /api/admin/users/:id/auth-tokens`
+
+Return a list of all auth tokens (devices) that the user currently have logged in from.
+
+Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
+
+**Example Request**:
+
+```http
+GET /api/admin/users/1/auth-tokens HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+[
+  {
+    "id": 361,
+    "isActive": false,
+    "clientIp": "127.0.0.1",
+    "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
+    "createdAt": "2019-03-05T21:22:54+01:00",
+    "seenAt": "2019-03-06T19:41:06+01:00"
+  },
+  {
+    "id": 364,
+    "isActive": false,
+    "clientIp": "127.0.0.1",
+    "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
+    "createdAt": "2019-03-06T19:41:19+01:00",
+    "seenAt": "2019-03-06T19:41:21+01:00"
+  }
+]
+```
+
+## Revoke auth token for User
+
+`POST /api/admin/users/:id/revoke-auth-token`
+
+Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in
+and will be required to authenticate again upon next activity.
+
+Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
+
+**Example Request**:
+
+```http
+POST /api/admin/users/1/revoke-auth-token HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+
+{
+  "authTokenId": 364
+}
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+  "message": "User auth token revoked"
+}
+```
+
+## Logout User
+
+`POST /api/admin/users/:id/logout`
+
+Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in
+and will be required to authenticate again upon next activity.
+
+Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
+
+**Example Request**:
+
+```http
+POST /api/admin/users/1/logout HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+  "message": "User auth token revoked"
+}
+```

+ 72 - 0
docs/sources/http_api/user.md

@@ -478,3 +478,75 @@ Content-Type: application/json
 
 {"message":"Dashboard unstarred"}
 ```
+
+## Auth tokens of the actual User
+
+`GET /api/user/auth-tokens`
+
+Return a list of all auth tokens (devices) that the actual user currently have logged in from.
+
+**Example Request**:
+
+```http
+GET /api/user/auth-tokens HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+[
+  {
+    "id": 361,
+    "isActive": true,
+    "clientIp": "127.0.0.1",
+    "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
+    "createdAt": "2019-03-05T21:22:54+01:00",
+    "seenAt": "2019-03-06T19:41:06+01:00"
+  },
+  {
+    "id": 364,
+    "isActive": false,
+    "clientIp": "127.0.0.1",
+    "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
+    "createdAt": "2019-03-06T19:41:19+01:00",
+    "seenAt": "2019-03-06T19:41:21+01:00"
+  }
+]
+```
+
+## Revoke an auth token of the actual User
+
+`POST /api/user/revoke-auth-token`
+
+Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in
+and will be required to authenticate again upon next activity.
+
+**Example Request**:
+
+```http
+POST /api/user/revoke-auth-token HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "authTokenId": 364
+}
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+  "message": "User auth token revoked"
+}
+```

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

@@ -179,7 +179,6 @@ Path to the certificate key file (if `protocol` is set to `https`).
 
 Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events
 to grafana log.
-<hr />
 
 <hr />
 
@@ -262,6 +261,19 @@ Set to `true` to log the sql calls and execution times.
 For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
 Defaults to private.
 
+<hr />
+
+## [remote_cache]
+
+### type
+
+Either `redis`, `memcached` or `database` default is `database`
+
+### connstr
+
+The remote cache connection string. Leave empty when using `database` since it will use the primary database.
+Redis example config: `addr=127.0.0.1:6379,pool_size=100,db=grafana`
+Memcache example: `127.0.0.1:11211`
 
 <hr />
 
@@ -399,6 +411,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
 
 <hr />
 
+## [dataproxy]
+
+### logging
+
+This enables data proxy logging, default is false.
+
+### timeout
+
+How long the data proxy should wait before timing out default is 30 (seconds)
+
+### send_user_header
+
+If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+
+<hr />
+
 ## [analytics]
 
 ### reporting_enabled

+ 4 - 6
packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx

@@ -11,17 +11,15 @@ jest.mock('jquery', () => ({
 const setup = (propOverrides?: object) => {
   const props: Props = {
     maxValue: 100,
-    valueMappings: [],
     minValue: 0,
-    prefix: '',
-    suffix: '',
     displayMode: 'basic',
     thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
-    unit: 'none',
     height: 300,
     width: 300,
-    value: 25,
-    decimals: 0,
+    value: {
+      text: '25',
+      numeric: 25,
+    },
     theme: getTheme(),
     orientation: VizOrientation.Horizontal,
   };

+ 24 - 44
packages/grafana-ui/src/components/BarGauge/BarGauge.tsx

@@ -3,26 +3,21 @@ import React, { PureComponent, CSSProperties, ReactNode } from 'react';
 import tinycolor from 'tinycolor2';
 
 // Utils
-import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
+import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils';
 
 // Types
-import { Themeable, TimeSeriesValue, Threshold, ValueMapping, VizOrientation } from '../../types';
+import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types';
 
 const BAR_SIZE_RATIO = 0.8;
 
 export interface Props extends Themeable {
   height: number;
-  unit: string;
   width: number;
   thresholds: Threshold[];
-  valueMappings: ValueMapping[];
-  value: TimeSeriesValue;
+  value: DisplayValue;
   maxValue: number;
   minValue: number;
   orientation: VizOrientation;
-  prefix?: string;
-  suffix?: string;
-  decimals?: number;
   displayMode: 'basic' | 'lcd' | 'gradient';
 }
 
@@ -30,44 +25,30 @@ export class BarGauge extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
     maxValue: 100,
     minValue: 0,
-    value: 100,
-    unit: 'none',
-    displayMode: 'basic',
+    value: {
+      text: '100',
+      numeric: 100,
+    },
+    displayMode: 'lcd',
     orientation: VizOrientation.Horizontal,
     thresholds: [],
-    valueMappings: [],
   };
 
   render() {
-    const { maxValue, minValue, unit, decimals, displayMode } = this.props;
-
-    const numericValue = this.getNumericValue();
-    const valuePercent = Math.min(numericValue / (maxValue - minValue), 1);
-
-    const formatFunc = getValueFormat(unit);
-    const valueFormatted = formatFunc(numericValue, decimals);
-
-    switch (displayMode) {
+    switch (this.props.displayMode) {
       case 'lcd':
-        return this.renderRetroBars(valueFormatted, valuePercent);
+        return this.renderRetroBars();
       case 'basic':
       case 'gradient':
       default:
-        return this.renderBasicAndGradientBars(valueFormatted, valuePercent);
-    }
-  }
-
-  getNumericValue(): number {
-    if (Number.isFinite(this.props.value as number)) {
-      return this.props.value as number;
+        return this.renderBasicAndGradientBars();
     }
-    return 0;
   }
 
   getValueColors(): BarColors {
     const { thresholds, theme, value } = this.props;
 
-    const activeThreshold = getThresholdForValue(thresholds, value);
+    const activeThreshold = getThresholdForValue(thresholds, value.numeric);
 
     if (activeThreshold !== null) {
       const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
@@ -111,9 +92,8 @@ export class BarGauge extends PureComponent<Props> {
   }
 
   getBarGradient(maxSize: number): string {
-    const { minValue, maxValue, thresholds } = this.props;
+    const { minValue, maxValue, thresholds, value } = this.props;
     const cssDirection = this.isVertical ? '0deg' : '90deg';
-    const currentValue = this.getNumericValue();
 
     let gradient = '';
     let lastpos = 0;
@@ -127,7 +107,7 @@ export class BarGauge extends PureComponent<Props> {
 
       if (gradient === '') {
         gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
-      } else if (currentValue < threshold.value) {
+      } else if (value.numeric < threshold.value) {
         break;
       } else {
         lastpos = pos;
@@ -135,18 +115,18 @@ export class BarGauge extends PureComponent<Props> {
       }
     }
 
-    console.log(gradient);
     return gradient + ')';
   }
 
-  renderBasicAndGradientBars(valueFormatted: string, valuePercent: number): ReactNode {
-    const { height, width, displayMode } = this.props;
+  renderBasicAndGradientBars(): ReactNode {
+    const { height, width, displayMode, maxValue, minValue, value } = this.props;
 
+    const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
     const maxSize = this.size * BAR_SIZE_RATIO;
     const barSize = Math.max(valuePercent * maxSize, 0);
     const colors = this.getValueColors();
     const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, spaceForText);
+    const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
     const isBasic = displayMode === 'basic';
 
     const containerStyles: CSSProperties = {
@@ -199,7 +179,7 @@ export class BarGauge extends PureComponent<Props> {
     return (
       <div style={containerStyles}>
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
         <div style={barStyles} />
       </div>
@@ -214,7 +194,7 @@ export class BarGauge extends PureComponent<Props> {
       const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
 
       // if we are past real value the cell is not "on"
-      if (value === null || (positionValue !== null && positionValue > value)) {
+      if (value === null || (positionValue !== null && positionValue > value.numeric)) {
         return {
           background: tinycolor(color)
             .setAlpha(0.15)
@@ -244,8 +224,8 @@ export class BarGauge extends PureComponent<Props> {
     };
   }
 
-  renderRetroBars(valueFormatted: string, valuePercent: number): ReactNode {
-    const { height, width, maxValue, minValue } = this.props;
+  renderRetroBars(): ReactNode {
+    const { height, width, maxValue, minValue, value } = this.props;
 
     const valueRange = maxValue - minValue;
     const maxSize = this.size * BAR_SIZE_RATIO;
@@ -254,7 +234,7 @@ export class BarGauge extends PureComponent<Props> {
     const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
     const colors = this.getValueColors();
     const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, spaceForText);
+    const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
 
     const containerStyles: CSSProperties = {
       width: `${width}px`,
@@ -305,7 +285,7 @@ export class BarGauge extends PureComponent<Props> {
       <div style={containerStyles}>
         {cells}
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
       </div>
     );

+ 5 - 2
packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap

@@ -27,10 +27,13 @@ exports[`Render BarGauge with basic options should render 1`] = `
   <div
     style={
       Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.3)",
-        "borderRight": "1px solid #7EB26D",
+        "background": "rgba(126, 178, 109, 0.15)",
+        "border": "1px solid #7EB26D",
+        "borderRadius": "3px",
+        "boxShadow": "0 0 4px #7EB26D",
         "height": "300px",
         "marginRight": "10px",
+        "transition": "width 1s",
         "width": "60px",
       }
     }

+ 4 - 88
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 import { shallow } from 'enzyme';
 
 import { Gauge, Props } from './Gauge';
-import { ValueMapping, MappingType } from '../../types';
 import { getTheme } from '../../themes';
 
 jest.mock('jquery', () => ({
@@ -12,19 +11,16 @@ jest.mock('jquery', () => ({
 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,
-    value: 25,
-    decimals: 0,
+    value: {
+      text: '25',
+      numeric: 25,
+    },
     theme: getTheme(),
   };
 
@@ -39,38 +35,6 @@ const setup = (propOverrides?: object) => {
   };
 };
 
-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' }] });
@@ -98,51 +62,3 @@ describe('Get thresholds formatted', () => {
     ]);
   });
 });
-
-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');
-  });
-});

+ 8 - 56
packages/grafana-ui/src/components/Gauge/Gauge.tsx

@@ -1,28 +1,20 @@
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
 
-import { ValueMapping, Threshold, GrafanaThemeType } from '../../types';
-import { getMappedValue } from '../../utils/valueMappings';
-import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
+import { Threshold, GrafanaThemeType } from '../../types';
+import { getColorFromHexRgbOrName } from '../../utils';
 import { Themeable } from '../../index';
-
-type GaugeValue = string | number | null;
+import { DisplayValue } from '../../utils/displayValue';
 
 export interface Props extends Themeable {
-  decimals?: number | null;
   height: number;
-  valueMappings: ValueMapping[];
   maxValue: number;
   minValue: number;
-  prefix: string;
   thresholds: Threshold[];
   showThresholdMarkers: boolean;
   showThresholdLabels: boolean;
-  stat: string;
-  suffix: string;
-  unit: string;
   width: number;
-  value: number;
+  value: DisplayValue;
 }
 
 const FONT_SCALE = 1;
@@ -32,15 +24,10 @@ export class Gauge extends PureComponent<Props> {
 
   static defaultProps: Partial<Props> = {
     maxValue: 100,
-    valueMappings: [],
     minValue: 0,
-    prefix: '',
     showThresholdMarkers: true,
     showThresholdLabels: false,
-    suffix: '',
     thresholds: [],
-    unit: 'none',
-    stat: 'avg',
   };
 
   componentDidMount() {
@@ -51,39 +38,6 @@ export class Gauge extends PureComponent<Props> {
     this.draw();
   }
 
-  formatValue(value: GaugeValue) {
-    const { decimals, valueMappings, prefix, suffix, unit } = this.props;
-
-    if (isNaN(value as number)) {
-      return value;
-    }
-
-    if (valueMappings.length > 0) {
-      const valueMappedValue = getMappedValue(valueMappings, value);
-      if (valueMappedValue) {
-        return `${prefix && prefix + ' '}${valueMappedValue.text}${suffix && ' ' + suffix}`;
-      }
-    }
-
-    const formatFunc = getValueFormat(unit);
-    const formattedValue = formatFunc(value as number, decimals);
-    const handleNoValueValue = formattedValue || 'no value';
-
-    return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
-  }
-
-  getFontColor(value: GaugeValue): string {
-    const { thresholds, theme } = this.props;
-
-    const activeThreshold = getThresholdForValue(thresholds, value);
-
-    if (activeThreshold !== null) {
-      return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
-    }
-
-    return '';
-  }
-
   getFormattedThresholds() {
     const { maxValue, minValue, thresholds, theme } = this.props;
 
@@ -112,15 +66,13 @@ export class Gauge extends PureComponent<Props> {
   draw() {
     const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props;
 
-    const formattedValue = this.formatValue(value) as string;
     const dimension = Math.min(width, height * 1.3);
     const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3;
 
     const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
     const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
     const thresholdMarkersWidth = gaugeWidth / 5;
-    const fontSize =
-      Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1);
+    const fontSize = Math.min(dimension / 5, 100) * (value.text !== null ? this.getFontScale(value.text.length) : 1);
     const thresholdLabelFontSize = fontSize / 2.5;
 
     const options: any = {
@@ -149,9 +101,9 @@ export class Gauge extends PureComponent<Props> {
             width: thresholdMarkersWidth,
           },
           value: {
-            color: this.getFontColor(value),
+            color: value.color,
             formatter: () => {
-              return formattedValue;
+              return value.text;
             },
             font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
           },
@@ -160,7 +112,7 @@ export class Gauge extends PureComponent<Props> {
       },
     };
 
-    const plotSeries = { data: [[0, value]] };
+    const plotSeries = { data: [[0, value.numeric]] };
 
     try {
       $.plot(this.canvasElement, [plotSeries], options);

+ 5 - 7
public/app/core/components/Form/Input.test.tsx → packages/grafana-ui/src/components/Input/Input.test.tsx

@@ -1,18 +1,16 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
 import { shallow } from 'enzyme';
-import { Input, EventsWithValidation } from './Input';
-import { ValidationEvents } from 'app/types';
+import { Input } from './Input';
+import { EventsWithValidation } from '../../utils';
+import { ValidationEvents } from '../../types';
 
 const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
 const testBlurValidation: ValidationEvents = {
   [EventsWithValidation.onBlur]: [
     {
       rule: (value: string) => {
-        if (!value || value.length < 3) {
-          return true;
-        }
-        return false;
+        return !value || value.length < 3;
       },
       errorMessage: TEST_ERROR_MESSAGE,
     },

+ 14 - 27
public/app/core/components/Form/Input.tsx → packages/grafana-ui/src/components/Input/Input.tsx

@@ -1,26 +1,13 @@
-import React, { PureComponent } from 'react';
+import React, { PureComponent, ChangeEvent } from 'react';
 import classNames from 'classnames';
-import { ValidationEvents, ValidationRule } from 'app/types';
-import { validate, hasValidationEvent } from 'app/core/utils/validate';
+import { validate, EventsWithValidation, hasValidationEvent } from '../../utils';
+import { ValidationEvents, ValidationRule } from '../../types';
 
 export enum InputStatus {
   Invalid = 'invalid',
   Valid = 'valid',
 }
 
-export enum InputTypes {
-  Text = 'text',
-  Number = 'number',
-  Password = 'password',
-  Email = 'email',
-}
-
-export enum EventsWithValidation {
-  onBlur = 'onBlur',
-  onFocus = 'onFocus',
-  onChange = 'onChange',
-}
-
 interface Props extends React.HTMLProps<HTMLInputElement> {
   validationEvents?: ValidationEvents;
   hideErrorMessage?: boolean;
@@ -28,7 +15,7 @@ interface Props extends React.HTMLProps<HTMLInputElement> {
   // Override event props and append status as argument
   onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
   onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
-  onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
+  onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: InputStatus) => void;
 }
 
 export class Input extends PureComponent<Props> {
@@ -49,24 +36,24 @@ export class Input extends PureComponent<Props> {
   }
 
   validatorAsync = (validationRules: ValidationRule[]) => {
-    return evt => {
+    return (evt: ChangeEvent<HTMLInputElement>) => {
       const errors = validate(evt.target.value, validationRules);
       this.setState(prevState => {
-        return {
-          ...prevState,
-          error: errors ? errors[0] : null,
-        };
+        return { ...prevState, error: errors ? errors[0] : null };
       });
     };
   };
 
-  populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
+  populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
     const inputElementProps = { ...restProps };
-    Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => {
-      if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) {
-        inputElementProps[eventName] = async evt => {
+    if (!validationEvents) {
+      return inputElementProps;
+    }
+    Object.keys(EventsWithValidation).forEach(eventName => {
+      if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
+        inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
           evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
-          if (hasValidationEvent(eventName, validationEvents)) {
+          if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
             await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
           }
           if (restProps[eventName]) {

+ 0 - 0
public/app/core/components/Form/__snapshots__/Input.test.tsx.snap → packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap


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

@@ -161,7 +161,7 @@ describe('change threshold value', () => {
 });
 
 describe('on blur threshold value', () => {
-  it.only('should resort rows and update indexes', () => {
+  it('should resort rows and update indexes', () => {
     const { instance } = setup();
     const thresholds = [
       { index: 0, value: -Infinity, color: '#7EB26D' },

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

@@ -25,6 +25,7 @@ export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
 export { Switch } from './Switch/Switch';
 export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
 export { UnitPicker } from './UnitPicker/UnitPicker';
+export { Input, InputStatus } from './Input/Input';
 
 // Visualizations
 export { Gauge } from './Gauge/Gauge';

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

@@ -5,3 +5,4 @@ export * from './plugin';
 export * from './datasource';
 export * from './theme';
 export * from './threshold';
+export * from './input';

+ 0 - 0
public/app/types/form.ts → packages/grafana-ui/src/types/input.ts


+ 2 - 2
packages/grafana-ui/src/types/panel.ts

@@ -30,10 +30,10 @@ export interface PanelEditorProps<T = any> {
  * Called before a panel is initalized
  */
 export type PanelTypeChangedHook<TOptions = any> = (
-  options: TOptions,
+  options: Partial<TOptions>,
   prevPluginId?: string,
   prevOptions?: any
-) => TOptions;
+) => Partial<TOptions>;
 
 export class ReactPanelPlugin<TOptions = any> {
   panel: ComponentClass<PanelProps<TOptions>>;

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

@@ -91,6 +91,7 @@ export interface PluginMeta {
   includes: PluginInclude[];
 
   // Datasource-specific
+  builtIn?: boolean;
   metrics?: boolean;
   tables?: boolean;
   logs?: boolean;

+ 157 - 0
packages/grafana-ui/src/utils/displayValue.test.ts

@@ -0,0 +1,157 @@
+import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, DisplayValue } from './displayValue';
+import { MappingType, ValueMapping } from '../types/panel';
+
+function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) {
+  processors.forEach(processor => {
+    const value = processor(input);
+    expect(value.text).toEqual(match.text);
+    if (match.hasOwnProperty('numeric')) {
+      expect(value.numeric).toEqual(match.numeric);
+    }
+  });
+}
+
+describe('Process simple display values', () => {
+  // Don't test float values here since the decimal formatting changes
+  const processors = [
+    // Without options, this shortcuts to a much easier implementation
+    getDisplayProcessor(),
+
+    // Add a simple option that is not used (uses a different base class)
+    getDisplayProcessor({ color: '#FFF' }),
+
+    // Add a simple option that is not used (uses a different base class)
+    getDisplayProcessor({ unit: 'locale' }),
+  ];
+
+  it('support null', () => {
+    assertSame(null, processors, { text: '', numeric: NaN });
+  });
+
+  it('support undefined', () => {
+    assertSame(undefined, processors, { text: '', numeric: NaN });
+  });
+
+  it('support NaN', () => {
+    assertSame(NaN, processors, { text: 'NaN', numeric: NaN });
+  });
+
+  it('Integer', () => {
+    assertSame(3, processors, { text: '3', numeric: 3 });
+  });
+
+  it('Text to number', () => {
+    assertSame('3', processors, { text: '3', numeric: 3 });
+  });
+
+  it('Simple String', () => {
+    assertSame('hello', processors, { text: 'hello', numeric: NaN });
+  });
+
+  it('empty array', () => {
+    assertSame([], processors, { text: '', numeric: NaN });
+  });
+
+  it('array of text', () => {
+    assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN });
+  });
+
+  it('array of numbers', () => {
+    assertSame([1, 2, 3], processors, { text: '1,2,3', numeric: NaN });
+  });
+
+  it('empty object', () => {
+    assertSame({}, processors, { text: '[object Object]', numeric: NaN });
+  });
+
+  it('boolean true', () => {
+    assertSame(true, processors, { text: 'true', numeric: 1 });
+  });
+
+  it('boolean false', () => {
+    assertSame(false, processors, { text: 'false', numeric: 0 });
+  });
+});
+
+describe('Processor with more configs', () => {
+  it('support prefix & suffix', () => {
+    const processor = getDisplayProcessor({
+      prefix: 'AA_',
+      suffix: '_ZZ',
+    });
+
+    expect(processor('XXX').text).toEqual('AA_XXX_ZZ');
+  });
+});
+
+describe('Get color from threshold', () => {
+  it('should get first threshold color when only one threshold', () => {
+    const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }];
+    expect(getColorFromThreshold(49, thresholds)).toEqual('#7EB26D');
+  });
+
+  it('should get the threshold color if value is same as a threshold', () => {
+    const thresholds = [
+      { index: 2, value: 75, color: '#6ED0E0' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ];
+    expect(getColorFromThreshold(50, thresholds)).toEqual('#EAB839');
+  });
+
+  it('should get the nearest threshold color between thresholds', () => {
+    const thresholds = [
+      { index: 2, value: 75, color: '#6ED0E0' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ];
+    expect(getColorFromThreshold(55, thresholds)).toEqual('#EAB839');
+  });
+});
+
+describe('Format value', () => {
+  it('should return if value isNaN', () => {
+    const valueMappings: ValueMapping[] = [];
+    const value = 'N/A';
+    const instance = getDisplayProcessor({ mappings: valueMappings });
+
+    const result = instance(value);
+
+    expect(result.text).toEqual('N/A');
+  });
+
+  it('should return formatted value if there are no value mappings', () => {
+    const valueMappings: ValueMapping[] = [];
+    const value = '6';
+
+    const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
+
+    const result = instance(value);
+
+    expect(result.text).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 = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
+
+    const result = instance(value);
+
+    expect(result.text).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 = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
+
+    expect(instance(value).text).toEqual('1-20');
+  });
+});

+ 145 - 0
packages/grafana-ui/src/utils/displayValue.ts

@@ -0,0 +1,145 @@
+import { ValueMapping, Threshold } from '../types';
+import _ from 'lodash';
+import { getValueFormat, DecimalCount } from './valueFormats/valueFormats';
+import { getMappedValue } from './valueMappings';
+import { GrafanaTheme, GrafanaThemeType } from '../types';
+import { getColorFromHexRgbOrName } from './namedColorsPalette';
+import moment from 'moment';
+
+export interface DisplayValue {
+  text: string; // Show in the UI
+  numeric: number; // Use isNaN to check if it is a real number
+  color?: string; // color based on configs or Threshold
+}
+
+export interface DisplayValueOptions {
+  unit?: string;
+  decimals?: DecimalCount;
+  scaledDecimals?: DecimalCount;
+  dateFormat?: string; // If set try to convert numbers to date
+
+  color?: string;
+  mappings?: ValueMapping[];
+  thresholds?: Threshold[];
+  prefix?: string;
+  suffix?: string;
+
+  // Alternative to empty string
+  noValue?: string;
+
+  // Context
+  isUtc?: boolean;
+  theme?: GrafanaTheme; // Will pick 'dark' if not defined
+}
+
+export type DisplayProcessor = (value: any) => DisplayValue;
+
+export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor {
+  if (options && !_.isEmpty(options)) {
+    const formatFunc = getValueFormat(options.unit || 'none');
+    return (value: any) => {
+      const { prefix, suffix, mappings, thresholds, theme } = options;
+      let color = options.color;
+
+      let text = _.toString(value);
+      let numeric = toNumber(value);
+
+      let shouldFormat = true;
+      if (mappings && mappings.length > 0) {
+        const mappedValue = getMappedValue(mappings, value);
+        if (mappedValue) {
+          text = mappedValue.text;
+          const v = toNumber(text);
+          if (!isNaN(v)) {
+            numeric = v;
+          }
+          shouldFormat = false;
+        }
+      }
+
+      if (options.dateFormat) {
+        const date = toMoment(value, numeric, options.dateFormat);
+        if (date.isValid()) {
+          text = date.format(options.dateFormat);
+          shouldFormat = false;
+        }
+      }
+
+      if (!isNaN(numeric)) {
+        if (shouldFormat && !_.isBoolean(value)) {
+          text = formatFunc(numeric, options.decimals, options.scaledDecimals, options.isUtc);
+        }
+        if (thresholds && thresholds.length > 0) {
+          color = getColorFromThreshold(numeric, thresholds, theme);
+        }
+      }
+
+      if (!text) {
+        text = options.noValue ? options.noValue : '';
+      }
+      if (prefix) {
+        text = prefix + text;
+      }
+      if (suffix) {
+        text = text + suffix;
+      }
+      return { text, numeric, color };
+    };
+  }
+  return toStringProcessor;
+}
+
+function toMoment(value: any, numeric: number, format: string): moment.Moment {
+  if (!isNaN(numeric)) {
+    const v = moment(numeric);
+    if (v.isValid()) {
+      return v;
+    }
+  }
+  const v = moment(value, format);
+  if (v.isValid) {
+    return v;
+  }
+  return moment(value); // moment will try to parse the format
+}
+
+/** Will return any value as a number or NaN */
+function toNumber(value: any): number {
+  if (typeof value === 'number') {
+    return value;
+  }
+  if (value === null || value === undefined || Array.isArray(value)) {
+    return NaN; // lodash calls them 0
+  }
+  if (typeof value === 'boolean') {
+    return value ? 1 : 0;
+  }
+  return _.toNumber(value);
+}
+
+function toStringProcessor(value: any): DisplayValue {
+  return { text: _.toString(value), numeric: toNumber(value) };
+}
+
+export function getColorFromThreshold(value: number, thresholds: Threshold[], theme?: GrafanaTheme): string {
+  const themeType = theme ? theme.type : GrafanaThemeType.Dark;
+
+  if (thresholds.length === 1) {
+    return getColorFromHexRgbOrName(thresholds[0].color, themeType);
+  }
+
+  const atThreshold = thresholds.filter(threshold => value === threshold.value)[0];
+  if (atThreshold) {
+    return getColorFromHexRgbOrName(atThreshold.color, themeType);
+  }
+
+  const belowThreshold = thresholds.filter(threshold => value > threshold.value);
+
+  if (belowThreshold.length > 0) {
+    const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
+    return getColorFromHexRgbOrName(nearestThreshold.color, themeType);
+  }
+
+  // Use the first threshold as the default color
+  return getColorFromHexRgbOrName(thresholds[0].color, themeType);
+}

+ 2 - 0
packages/grafana-ui/src/utils/index.ts

@@ -5,5 +5,7 @@ export * from './colors';
 export * from './namedColorsPalette';
 export * from './thresholds';
 export * from './string';
+export * from './displayValue';
 export * from './deprecationWarning';
 export { getMappedValue } from './valueMappings';
+export * from './validate';

+ 24 - 0
packages/grafana-ui/src/utils/validate.ts

@@ -0,0 +1,24 @@
+import { ValidationRule, ValidationEvents } from '../types/input';
+
+export enum EventsWithValidation {
+  onBlur = 'onBlur',
+  onFocus = 'onFocus',
+  onChange = 'onChange',
+}
+
+export const validate = (value: string, validationRules: ValidationRule[]) => {
+  const errors = validationRules.reduce(
+    (acc, currRule) => {
+      if (!currRule.rule(value)) {
+        return acc.concat(currRule.errorMessage);
+      }
+      return acc;
+    },
+    [] as string[]
+  );
+  return errors.length > 0 ? errors : null;
+};
+
+export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => {
+  return validationEvents && validationEvents[event];
+};

+ 23 - 0
pkg/api/admin_users.go

@@ -110,3 +110,26 @@ func AdminDeleteUser(c *m.ReqContext) {
 
 	c.JsonOK("User deleted")
 }
+
+// POST /api/admin/users/:id/logout
+func (server *HTTPServer) AdminLogoutUser(c *m.ReqContext) Response {
+	userID := c.ParamsInt64(":id")
+
+	if c.UserId == userID {
+		return Error(400, "You cannot logout yourself", nil)
+	}
+
+	return server.logoutUserFromAllDevicesInternal(userID)
+}
+
+// GET /api/admin/users/:id/auth-tokens
+func (server *HTTPServer) AdminGetUserAuthTokens(c *m.ReqContext) Response {
+	userID := c.ParamsInt64(":id")
+	return server.getUserAuthTokensInternal(c, userID)
+}
+
+// POST /api/admin/users/:id/revoke-auth-token
+func (server *HTTPServer) AdminRevokeUserAuthToken(c *m.ReqContext, cmd m.RevokeAuthTokenCmd) Response {
+	userID := c.ParamsInt64(":id")
+	return server.revokeUserAuthTokenInternal(c, userID, cmd)
+}

+ 138 - 0
pkg/api/admin_users_test.go

@@ -6,6 +6,7 @@ import (
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 
 	. "github.com/smartystreets/goconvey/convey"
 )
@@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) {
 			So(sc.resp.Code, ShouldEqual, 400)
 		})
 	})
+
+	Convey("When a server admin attempts to logout himself from all devices", t, func() {
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			cmd.Result = &m.User{Id: TestUserID}
+			return nil
+		})
+
+		adminLogoutUserScenario("Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 400)
+		})
+	})
+
+	Convey("When a server admin attempts to logout a non-existing user from all devices", t, func() {
+		userId := int64(0)
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			userId = cmd.Id
+			return m.ErrUserNotFound
+		})
+
+		adminLogoutUserScenario("Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+			So(userId, ShouldEqual, 200)
+		})
+	})
+
+	Convey("When a server admin attempts to revoke an auth token for a non-existing user", t, func() {
+		userId := int64(0)
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			userId = cmd.Id
+			return m.ErrUserNotFound
+		})
+
+		cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+
+		adminRevokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) {
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+			So(userId, ShouldEqual, 200)
+		})
+	})
+
+	Convey("When a server admin gets auth tokens for a non-existing user", t, func() {
+		userId := int64(0)
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			userId = cmd.Id
+			return m.ErrUserNotFound
+		})
+
+		adminGetUserAuthTokensScenario("Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) {
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+			So(userId, ShouldEqual, 200)
+		})
+	})
 }
 
 func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) {
@@ -48,3 +105,84 @@ func putAdminScenario(desc string, url string, routePattern string, role m.RoleT
 		fn(sc)
 	})
 }
+
+func adminLogoutUserScenario(desc string, url string, routePattern string, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: auth.NewFakeUserAuthTokenService(),
+		}
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+
+			return hs.AdminLogoutUser(c)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func adminRevokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: fakeAuthTokenService,
+		}
+
+		sc := setupScenarioContext(url)
+		sc.userAuthTokenService = fakeAuthTokenService
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+
+			return hs.AdminRevokeUserAuthToken(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func adminGetUserAuthTokensScenario(desc string, url string, routePattern string, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: fakeAuthTokenService,
+		}
+
+		sc := setupScenarioContext(url)
+		sc.userAuthTokenService = fakeAuthTokenService
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+
+			return hs.AdminGetUserAuthTokens(c)
+		})
+
+		sc.m.Get(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 7 - 0
pkg/api/api.go

@@ -133,6 +133,9 @@ func (hs *HTTPServer) registerRoutes() {
 
 			userRoute.Get("/preferences", Wrap(GetUserPreferences))
 			userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences))
+
+			userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens))
+			userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken))
 		})
 
 		// users (admin permission required)
@@ -375,6 +378,10 @@ func (hs *HTTPServer) registerRoutes() {
 		adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
 		adminRoute.Get("/stats", AdminGetStats)
 		adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts))
+
+		adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser))
+		adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens))
+		adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
 	}, reqGrafanaAdmin)
 
 	// rendering

+ 3 - 3
pkg/api/app_routes.go

@@ -48,18 +48,18 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) {
 					handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN))
 				}
 			}
-			handlers = append(handlers, AppPluginRoute(route, plugin.Id))
+			handlers = append(handlers, AppPluginRoute(route, plugin.Id, hs))
 			r.Route(url, route.Method, handlers...)
 			log.Debug("Plugins: Adding proxy route %s", url)
 		}
 	}
 }
 
-func AppPluginRoute(route *plugins.AppPluginRoute, appID string) macaron.Handler {
+func AppPluginRoute(route *plugins.AppPluginRoute, appID string, hs *HTTPServer) macaron.Handler {
 	return func(c *m.ReqContext) {
 		path := c.Params("*")
 
-		proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID)
+		proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg)
 		proxy.Transport = pluginProxyTransport
 		proxy.ServeHTTP(c.Resp, c.Req.Request)
 	}

+ 9 - 7
pkg/api/common_test.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"gopkg.in/macaron.v1"
 
 	. "github.com/smartystreets/goconvey/convey"
@@ -94,13 +95,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 *auth.FakeUserAuthTokenService
 }
 
 func (sc *scenarioContext) exec() {

+ 1 - 1
pkg/api/dataproxy.go

@@ -31,7 +31,7 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
 	// macaron does not include trailing slashes when resolving a wildcard path
 	proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
 
-	proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath)
+	proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath, hs.Cfg)
 	proxy.HandleRequest()
 }
 

+ 12 - 0
pkg/api/dtos/user_token.go

@@ -0,0 +1,12 @@
+package dtos
+
+import "time"
+
+type UserToken struct {
+	Id        int64     `json:"id"`
+	IsActive  bool      `json:"isActive"`
+	ClientIp  string    `json:"clientIp"`
+	UserAgent string    `json:"userAgent"`
+	CreatedAt time.Time `json:"createdAt"`
+	SeenAt    time.Time `json:"seenAt"`
+}

+ 7 - 1
pkg/api/pluginproxy/ds_proxy.go

@@ -34,13 +34,14 @@ type DataSourceProxy struct {
 	proxyPath string
 	route     *plugins.AppPluginRoute
 	plugin    *plugins.DataSourcePlugin
+	cfg       *setting.Cfg
 }
 
 type httpClient interface {
 	Do(req *http.Request) (*http.Response, error)
 }
 
-func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string) *DataSourceProxy {
+func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string, cfg *setting.Cfg) *DataSourceProxy {
 	targetURL, _ := url.Parse(ds.Url)
 
 	return &DataSourceProxy{
@@ -49,6 +50,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
 		ctx:       ctx,
 		proxyPath: proxyPath,
 		targetUrl: targetURL,
+		cfg:       cfg,
 	}
 }
 
@@ -170,6 +172,10 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
 			req.Header.Add("Authorization", dsAuth)
 		}
 
+		if proxy.cfg.SendUserHeader && !proxy.ctx.SignedInUser.IsAnonymous {
+			req.Header.Add("X-Grafana-User", proxy.ctx.SignedInUser.Login)
+		}
+
 		// clear cookie header, except for whitelisted cookies
 		var keptCookies []*http.Cookie
 		if proxy.ds.JsonData != nil {

+ 73 - 14
pkg/api/pluginproxy/ds_proxy_test.go

@@ -81,7 +81,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 
 			Convey("When matching route path", func() {
-				proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
+				proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{})
 				proxy.route = plugin.Routes[0]
 				ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 
@@ -92,7 +92,7 @@ func TestDSRouteRule(t *testing.T) {
 			})
 
 			Convey("When matching route path and has dynamic url", func() {
-				proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method")
+				proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method", &setting.Cfg{})
 				proxy.route = plugin.Routes[3]
 				ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 
@@ -104,20 +104,20 @@ func TestDSRouteRule(t *testing.T) {
 
 			Convey("Validating request", func() {
 				Convey("plugin route with valid role", func() {
-					proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
+					proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{})
 					err := proxy.validateRequest()
 					So(err, ShouldBeNil)
 				})
 
 				Convey("plugin route with admin role and user is editor", func() {
-					proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin")
+					proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{})
 					err := proxy.validateRequest()
 					So(err, ShouldNotBeNil)
 				})
 
 				Convey("plugin route with admin role and user is admin", func() {
 					ctx.SignedInUser.OrgRole = m.ROLE_ADMIN
-					proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin")
+					proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{})
 					err := proxy.validateRequest()
 					So(err, ShouldBeNil)
 				})
@@ -186,7 +186,7 @@ func TestDSRouteRule(t *testing.T) {
 					So(err, ShouldBeNil)
 
 					client = newFakeHTTPClient(json)
-					proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
+					proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
 					proxy1.route = plugin.Routes[0]
 					ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds)
 
@@ -200,7 +200,7 @@ func TestDSRouteRule(t *testing.T) {
 
 						req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
 						client = newFakeHTTPClient(json2)
-						proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2")
+						proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", &setting.Cfg{})
 						proxy2.route = plugin.Routes[1]
 						ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds)
 
@@ -215,7 +215,7 @@ func TestDSRouteRule(t *testing.T) {
 							req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
 
 							client = newFakeHTTPClient([]byte{})
-							proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
+							proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
 							proxy3.route = plugin.Routes[0]
 							ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds)
 
@@ -236,7 +236,7 @@ func TestDSRouteRule(t *testing.T) {
 			ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE}
 			ctx := &m.ReqContext{}
 
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "/render")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{})
 			req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
 			So(err, ShouldBeNil)
 
@@ -261,7 +261,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 			req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
 			So(err, ShouldBeNil)
@@ -291,7 +291,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -317,7 +317,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -347,7 +347,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -369,7 +369,7 @@ func TestDSRouteRule(t *testing.T) {
 				Url:  "http://host/root/",
 			}
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{})
 			req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
 			req.Header.Add("Origin", "grafana.com")
 			req.Header.Add("Referer", "grafana.com")
@@ -388,9 +388,68 @@ func TestDSRouteRule(t *testing.T) {
 				So(req.Header.Get("X-Canary"), ShouldEqual, "stillthere")
 			})
 		})
+
+		Convey("When SendUserHeader config is enabled", func() {
+			req := getDatasourceProxiedRequest(
+				&m.ReqContext{
+					SignedInUser: &m.SignedInUser{
+						Login: "test_user",
+					},
+				},
+				&setting.Cfg{SendUserHeader: true},
+			)
+			Convey("Should add header with username", func() {
+				So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
+			})
+		})
+
+		Convey("When SendUserHeader config is disabled", func() {
+			req := getDatasourceProxiedRequest(
+				&m.ReqContext{
+					SignedInUser: &m.SignedInUser{
+						Login: "test_user",
+					},
+				},
+				&setting.Cfg{SendUserHeader: false},
+			)
+			Convey("Should not add header with username", func() {
+				// Get will return empty string even if header is not set
+				So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+			})
+		})
+
+		Convey("When SendUserHeader config is enabled but user is anonymous", func() {
+			req := getDatasourceProxiedRequest(
+				&m.ReqContext{
+					SignedInUser: &m.SignedInUser{IsAnonymous: true},
+				},
+				&setting.Cfg{SendUserHeader: true},
+			)
+			Convey("Should not add header with username", func() {
+				// Get will return empty string even if header is not set
+				So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+			})
+		})
 	})
 }
 
+// getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
+func getDatasourceProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
+	plugin := &plugins.DataSourcePlugin{}
+
+	ds := &m.DataSource{
+		Type: "custom",
+		Url:  "http://host/root/",
+	}
+
+	proxy := NewDataSourceProxy(ds, plugin, ctx, "", cfg)
+	req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
+	So(err, ShouldBeNil)
+
+	proxy.getDirector()(req)
+	return req
+}
+
 type httpClientStub struct {
 	fakeBody []byte
 }

+ 6 - 1
pkg/api/pluginproxy/pluginproxy.go

@@ -2,6 +2,7 @@ package pluginproxy
 
 import (
 	"encoding/json"
+	"github.com/grafana/grafana/pkg/setting"
 	"net"
 	"net/http"
 	"net/http/httputil"
@@ -37,7 +38,7 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http.
 	return result, err
 }
 
-func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string) *httputil.ReverseProxy {
+func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy {
 	targetURL, _ := url.Parse(route.Url)
 
 	director := func(req *http.Request) {
@@ -79,6 +80,10 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
 
 		req.Header.Add("X-Grafana-Context", string(ctxJson))
 
+		if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous {
+			req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login)
+		}
+
 		if len(route.Headers) > 0 {
 			headers, err := getHeaders(route, ctx.OrgId, appID)
 			if err != nil {

+ 56 - 0
pkg/api/pluginproxy/pluginproxy_test.go

@@ -1,6 +1,7 @@
 package pluginproxy
 
 import (
+	"net/http"
 	"testing"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -44,4 +45,59 @@ func TestPluginProxy(t *testing.T) {
 		})
 	})
 
+	Convey("When SendUserHeader config is enabled", t, func() {
+		req := getPluginProxiedRequest(
+			&m.ReqContext{
+				SignedInUser: &m.SignedInUser{
+					Login: "test_user",
+				},
+			},
+			&setting.Cfg{SendUserHeader: true},
+		)
+
+		Convey("Should add header with username", func() {
+			// Get will return empty string even if header is not set
+			So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
+		})
+	})
+
+	Convey("When SendUserHeader config is disabled", t, func() {
+		req := getPluginProxiedRequest(
+			&m.ReqContext{
+				SignedInUser: &m.SignedInUser{
+					Login: "test_user",
+				},
+			},
+			&setting.Cfg{SendUserHeader: false},
+		)
+		Convey("Should not add header with username", func() {
+			// Get will return empty string even if header is not set
+			So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+		})
+	})
+
+	Convey("When SendUserHeader config is enabled but user is anonymous", t, func() {
+		req := getPluginProxiedRequest(
+			&m.ReqContext{
+				SignedInUser: &m.SignedInUser{IsAnonymous: true},
+			},
+			&setting.Cfg{SendUserHeader: true},
+		)
+
+		Convey("Should not add header with username", func() {
+			// Get will return empty string even if header is not set
+			So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+		})
+	})
+}
+
+// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
+func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
+	route := &plugins.AppPluginRoute{}
+	proxy := NewApiPluginProxy(ctx, "", route, "", cfg)
+
+	req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
+	So(err, ShouldBeNil)
+	proxy.Director(req)
+	return req
 }

+ 110 - 0
pkg/api/user_token.go

@@ -0,0 +1,110 @@
+package api
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+// GET /api/user/auth-tokens
+func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response {
+	return server.getUserAuthTokensInternal(c, c.UserId)
+}
+
+// POST /api/user/revoke-auth-token
+func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response {
+	return server.revokeUserAuthTokenInternal(c, c.UserId, cmd)
+}
+
+func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response {
+	userQuery := models.GetUserByIdQuery{Id: userID}
+
+	if err := bus.Dispatch(&userQuery); err != nil {
+		if err == models.ErrUserNotFound {
+			return Error(404, "User not found", err)
+		}
+		return Error(500, "Could not read user from database", err)
+	}
+
+	err := server.AuthTokenService.RevokeAllUserTokens(userID)
+	if err != nil {
+		return Error(500, "Failed to logout user", err)
+	}
+
+	return JSON(200, util.DynMap{
+		"message": "User logged out",
+	})
+}
+
+func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response {
+	userQuery := models.GetUserByIdQuery{Id: userID}
+
+	if err := bus.Dispatch(&userQuery); err != nil {
+		if err == models.ErrUserNotFound {
+			return Error(404, "User not found", err)
+		}
+		return Error(500, "Failed to get user", err)
+	}
+
+	tokens, err := server.AuthTokenService.GetUserTokens(userID)
+	if err != nil {
+		return Error(500, "Failed to get user auth tokens", err)
+	}
+
+	result := []*dtos.UserToken{}
+	for _, token := range tokens {
+		isActive := false
+		if c.UserToken != nil && c.UserToken.Id == token.Id {
+			isActive = true
+		}
+
+		result = append(result, &dtos.UserToken{
+			Id:        token.Id,
+			IsActive:  isActive,
+			ClientIp:  token.ClientIp,
+			UserAgent: token.UserAgent,
+			CreatedAt: time.Unix(token.CreatedAt, 0),
+			SeenAt:    time.Unix(token.SeenAt, 0),
+		})
+	}
+
+	return JSON(200, result)
+}
+
+func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response {
+	userQuery := models.GetUserByIdQuery{Id: userID}
+
+	if err := bus.Dispatch(&userQuery); err != nil {
+		if err == models.ErrUserNotFound {
+			return Error(404, "User not found", err)
+		}
+		return Error(500, "Failed to get user", err)
+	}
+
+	token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId)
+	if err != nil {
+		if err == models.ErrUserTokenNotFound {
+			return Error(404, "User auth token not found", err)
+		}
+		return Error(500, "Failed to get user auth token", err)
+	}
+
+	if c.UserToken != nil && c.UserToken.Id == token.Id {
+		return Error(400, "Cannot revoke active user auth token", nil)
+	}
+
+	err = server.AuthTokenService.RevokeToken(token)
+	if err != nil {
+		if err == models.ErrUserTokenNotFound {
+			return Error(404, "User auth token not found", err)
+		}
+		return Error(500, "Failed to revoke user auth token", err)
+	}
+
+	return JSON(200, util.DynMap{
+		"message": "User auth token revoked",
+	})
+}

+ 294 - 0
pkg/api/user_token_test.go

@@ -0,0 +1,294 @@
+package api
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserTokenApiEndpoint(t *testing.T) {
+	Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() {
+		userId := int64(0)
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			userId = cmd.Id
+			return m.ErrUserNotFound
+		})
+
+		cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+
+		revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) {
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+			So(userId, ShouldEqual, 200)
+		})
+	})
+
+	Convey("When current user gets auth tokens for a non-existing user", t, func() {
+		userId := int64(0)
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			userId = cmd.Id
+			return m.ErrUserNotFound
+		})
+
+		getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) {
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+			So(userId, ShouldEqual, 200)
+		})
+	})
+
+	Convey("When logout an existing user from all devices", t, func() {
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			cmd.Result = &m.User{Id: 200}
+			return nil
+		})
+
+		logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) {
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 200)
+		})
+	})
+
+	Convey("When logout a non-existing user from all devices", t, func() {
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			return m.ErrUserNotFound
+		})
+
+		logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) {
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+		})
+	})
+
+	Convey("When revoke an auth token for a user", t, func() {
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			cmd.Result = &m.User{Id: 200}
+			return nil
+		})
+
+		cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+		token := &m.UserToken{Id: 1}
+
+		revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) {
+			sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
+				return &m.UserToken{Id: 2}, nil
+			}
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 200)
+		})
+	})
+
+	Convey("When revoke the active auth token used by himself", t, func() {
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			cmd.Result = &m.User{Id: TestUserID}
+			return nil
+		})
+
+		cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+		token := &m.UserToken{Id: 2}
+
+		revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) {
+			sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
+				return token, nil
+			}
+			sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 400)
+		})
+	})
+
+	Convey("When gets auth tokens for a user", t, func() {
+		bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+			cmd.Result = &m.User{Id: TestUserID}
+			return nil
+		})
+
+		currentToken := &m.UserToken{Id: 1}
+
+		getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) {
+			tokens := []*m.UserToken{
+				{
+					Id:        1,
+					ClientIp:  "127.0.0.1",
+					UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
+					CreatedAt: time.Now().Unix(),
+					SeenAt:    time.Now().Unix(),
+				},
+				{
+					Id:        2,
+					ClientIp:  "127.0.0.2",
+					UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
+					CreatedAt: time.Now().Unix(),
+					SeenAt:    time.Now().Unix(),
+				},
+			}
+			sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) {
+				return tokens, nil
+			}
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+			So(sc.resp.Code, ShouldEqual, 200)
+			result := sc.ToJSON()
+			So(result.MustArray(), ShouldHaveLength, 2)
+
+			resultOne := result.GetIndex(0)
+			So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id)
+			So(resultOne.Get("isActive").MustBool(), ShouldBeTrue)
+			So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1")
+			So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36")
+			So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339))
+			So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339))
+
+			resultTwo := result.GetIndex(1)
+			So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id)
+			So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse)
+			So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2")
+			So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1")
+			So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
+			So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339))
+		})
+	})
+}
+
+func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: fakeAuthTokenService,
+		}
+
+		sc := setupScenarioContext(url)
+		sc.userAuthTokenService = fakeAuthTokenService
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = userId
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+
+			return hs.RevokeUserAuthToken(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: fakeAuthTokenService,
+		}
+
+		sc := setupScenarioContext(url)
+		sc.userAuthTokenService = fakeAuthTokenService
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = userId
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+
+			return hs.GetUserAuthTokens(c)
+		})
+
+		sc.m.Get(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) {
+	Convey(desc, func() {
+		defer bus.ClearBusHandlers()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: auth.NewFakeUserAuthTokenService(),
+		}
+
+		sc := setupScenarioContext("/")
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+
+			return hs.logoutUserFromAllDevicesInternal(userId)
+		})
+
+		sc.m.Post("/", sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) {
+	Convey(desc, func() {
+		defer bus.ClearBusHandlers()
+
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: fakeAuthTokenService,
+		}
+
+		sc := setupScenarioContext("/")
+		sc.userAuthTokenService = fakeAuthTokenService
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+			sc.context.UserToken = token
+
+			return hs.revokeUserAuthTokenInternal(c, userId, cmd)
+		})
+
+		sc.m.Post("/", sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) {
+	Convey(desc, func() {
+		defer bus.ClearBusHandlers()
+
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+		hs := HTTPServer{
+			Bus:              bus.GetBus(),
+			AuthTokenService: fakeAuthTokenService,
+		}
+
+		sc := setupScenarioContext("/")
+		sc.userAuthTokenService = fakeAuthTokenService
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = m.ROLE_ADMIN
+			sc.context.UserToken = token
+
+			return hs.getUserAuthTokensInternal(c, TestUserID)
+		})
+
+		sc.m.Get("/", sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 1 - 0
pkg/cmd/grafana-server/server.go

@@ -29,6 +29,7 @@ import (
 	// self registering services
 	_ "github.com/grafana/grafana/pkg/extensions"
 	_ "github.com/grafana/grafana/pkg/infra/metrics"
+	_ "github.com/grafana/grafana/pkg/infra/remotecache"
 	_ "github.com/grafana/grafana/pkg/infra/serverlock"
 	_ "github.com/grafana/grafana/pkg/infra/tracing"
 	_ "github.com/grafana/grafana/pkg/infra/usagestats"

+ 126 - 0
pkg/infra/remotecache/database_storage.go

@@ -0,0 +1,126 @@
+package remotecache
+
+import (
+	"context"
+	"time"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+)
+
+var getTime = time.Now
+
+const databaseCacheType = "database"
+
+type databaseCache struct {
+	SQLStore *sqlstore.SqlStore
+	log      log.Logger
+}
+
+func newDatabaseCache(sqlstore *sqlstore.SqlStore) *databaseCache {
+	dc := &databaseCache{
+		SQLStore: sqlstore,
+		log:      log.New("remotecache.database"),
+	}
+
+	return dc
+}
+
+func (dc *databaseCache) Run(ctx context.Context) error {
+	ticker := time.NewTicker(time.Minute * 10)
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-ticker.C:
+			dc.internalRunGC()
+		}
+	}
+}
+
+func (dc *databaseCache) internalRunGC() {
+	now := getTime().Unix()
+	sql := `DELETE FROM cache_data WHERE (? - created_at) >= expires AND expires <> 0`
+
+	_, err := dc.SQLStore.NewSession().Exec(sql, now)
+	if err != nil {
+		dc.log.Error("failed to run garbage collect", "error", err)
+	}
+}
+
+func (dc *databaseCache) Get(key string) (interface{}, error) {
+	cacheHit := CacheData{}
+	session := dc.SQLStore.NewSession()
+	defer session.Close()
+
+	exist, err := session.Where("cache_key= ?", key).Get(&cacheHit)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if !exist {
+		return nil, ErrCacheItemNotFound
+	}
+
+	if cacheHit.Expires > 0 {
+		existedButExpired := getTime().Unix()-cacheHit.CreatedAt >= cacheHit.Expires
+		if existedButExpired {
+			_ = dc.Delete(key) //ignore this error since we will return `ErrCacheItemNotFound` anyway
+			return nil, ErrCacheItemNotFound
+		}
+	}
+
+	item := &cachedItem{}
+	if err = decodeGob(cacheHit.Data, item); err != nil {
+		return nil, err
+	}
+
+	return item.Val, nil
+}
+
+func (dc *databaseCache) Set(key string, value interface{}, expire time.Duration) error {
+	item := &cachedItem{Val: value}
+	data, err := encodeGob(item)
+	if err != nil {
+		return err
+	}
+
+	session := dc.SQLStore.NewSession()
+
+	var cacheHit CacheData
+	has, err := session.Where("cache_key = ?", key).Get(&cacheHit)
+	if err != nil {
+		return err
+	}
+
+	var expiresInSeconds int64
+	if expire != 0 {
+		expiresInSeconds = int64(expire) / int64(time.Second)
+	}
+
+	// insert or update depending on if item already exist
+	if has {
+		sql := `UPDATE cache_data SET data=?, created=?, expire=? WHERE cache_key='?'`
+		_, err = session.Exec(sql, data, getTime().Unix(), expiresInSeconds, key)
+	} else {
+		sql := `INSERT INTO cache_data (cache_key,data,created_at,expires) VALUES(?,?,?,?)`
+		_, err = session.Exec(sql, key, data, getTime().Unix(), expiresInSeconds)
+	}
+
+	return err
+}
+
+func (dc *databaseCache) Delete(key string) error {
+	sql := "DELETE FROM cache_data WHERE cache_key=?"
+	_, err := dc.SQLStore.NewSession().Exec(sql, key)
+
+	return err
+}
+
+type CacheData struct {
+	CacheKey  string
+	Data      []byte
+	Expires   int64
+	CreatedAt int64
+}

+ 56 - 0
pkg/infra/remotecache/database_storage_test.go

@@ -0,0 +1,56 @@
+package remotecache
+
+import (
+	"testing"
+	"time"
+
+	"github.com/bmizerany/assert"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+)
+
+func TestDatabaseStorageGarbageCollection(t *testing.T) {
+	sqlstore := sqlstore.InitTestDB(t)
+
+	db := &databaseCache{
+		SQLStore: sqlstore,
+		log:      log.New("remotecache.database"),
+	}
+
+	obj := &CacheableStruct{String: "foolbar"}
+
+	//set time.now to 2 weeks ago
+	var err error
+	getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) }
+	err = db.Set("key1", obj, 1000*time.Second)
+	assert.Equal(t, err, nil)
+
+	err = db.Set("key2", obj, 1000*time.Second)
+	assert.Equal(t, err, nil)
+
+	err = db.Set("key3", obj, 1000*time.Second)
+	assert.Equal(t, err, nil)
+
+	// insert object that should never expire
+	db.Set("key4", obj, 0)
+
+	getTime = time.Now
+	db.Set("key5", obj, 1000*time.Second)
+
+	//run GC
+	db.internalRunGC()
+
+	//try to read values
+	_, err = db.Get("key1")
+	assert.Equal(t, err, ErrCacheItemNotFound, "expected cache item not found. got: ", err)
+	_, err = db.Get("key2")
+	assert.Equal(t, err, ErrCacheItemNotFound)
+	_, err = db.Get("key3")
+	assert.Equal(t, err, ErrCacheItemNotFound)
+
+	_, err = db.Get("key4")
+	assert.Equal(t, err, nil)
+	_, err = db.Get("key5")
+	assert.Equal(t, err, nil)
+}

+ 71 - 0
pkg/infra/remotecache/memcached_storage.go

@@ -0,0 +1,71 @@
+package remotecache
+
+import (
+	"time"
+
+	"github.com/bradfitz/gomemcache/memcache"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+const memcachedCacheType = "memcached"
+
+type memcachedStorage struct {
+	c *memcache.Client
+}
+
+func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage {
+	return &memcachedStorage{
+		c: memcache.New(opts.ConnStr),
+	}
+}
+
+func newItem(sid string, data []byte, expire int32) *memcache.Item {
+	return &memcache.Item{
+		Key:        sid,
+		Value:      data,
+		Expiration: expire,
+	}
+}
+
+// Set sets value to given key in the cache.
+func (s *memcachedStorage) Set(key string, val interface{}, expires time.Duration) error {
+	item := &cachedItem{Val: val}
+	bytes, err := encodeGob(item)
+	if err != nil {
+		return err
+	}
+
+	var expiresInSeconds int64
+	if expires != 0 {
+		expiresInSeconds = int64(expires) / int64(time.Second)
+	}
+
+	memcachedItem := newItem(key, bytes, int32(expiresInSeconds))
+	return s.c.Set(memcachedItem)
+}
+
+// Get gets value by given key in the cache.
+func (s *memcachedStorage) Get(key string) (interface{}, error) {
+	memcachedItem, err := s.c.Get(key)
+	if err != nil && err.Error() == "memcache: cache miss" {
+		return nil, ErrCacheItemNotFound
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	item := &cachedItem{}
+
+	err = decodeGob(memcachedItem.Value, item)
+	if err != nil {
+		return nil, err
+	}
+
+	return item.Val, nil
+}
+
+// Delete delete a key from the cache
+func (s *memcachedStorage) Delete(key string) error {
+	return s.c.Delete(key)
+}

+ 15 - 0
pkg/infra/remotecache/memcached_storage_integration_test.go

@@ -0,0 +1,15 @@
+// +build memcached
+
+package remotecache
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+func TestMemcachedCacheStorage(t *testing.T) {
+	opts := &setting.RemoteCacheOptions{Name: memcachedCacheType, ConnStr: "localhost:11211"}
+	client := createTestClient(t, opts, nil)
+	runTestsForClient(t, client)
+}

+ 62 - 0
pkg/infra/remotecache/redis_storage.go

@@ -0,0 +1,62 @@
+package remotecache
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/setting"
+	redis "gopkg.in/redis.v2"
+)
+
+const redisCacheType = "redis"
+
+type redisStorage struct {
+	c *redis.Client
+}
+
+func newRedisStorage(opts *setting.RemoteCacheOptions) *redisStorage {
+	opt := &redis.Options{
+		Network: "tcp",
+		Addr:    opts.ConnStr,
+	}
+	return &redisStorage{c: redis.NewClient(opt)}
+}
+
+// Set sets value to given key in session.
+func (s *redisStorage) Set(key string, val interface{}, expires time.Duration) error {
+	item := &cachedItem{Val: val}
+	value, err := encodeGob(item)
+	if err != nil {
+		return err
+	}
+
+	status := s.c.SetEx(key, expires, string(value))
+	return status.Err()
+}
+
+// Get gets value by given key in session.
+func (s *redisStorage) Get(key string) (interface{}, error) {
+	v := s.c.Get(key)
+
+	item := &cachedItem{}
+	err := decodeGob([]byte(v.Val()), item)
+
+	if err == nil {
+		return item.Val, nil
+	}
+
+	if err.Error() == "EOF" {
+		return nil, ErrCacheItemNotFound
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return item.Val, nil
+}
+
+// Delete delete a key from session.
+func (s *redisStorage) Delete(key string) error {
+	cmd := s.c.Del(key)
+	return cmd.Err()
+}

+ 16 - 0
pkg/infra/remotecache/redis_storage_integration_test.go

@@ -0,0 +1,16 @@
+// +build redis
+
+package remotecache
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+func TestRedisCacheStorage(t *testing.T) {
+
+	opts := &setting.RemoteCacheOptions{Name: redisCacheType, ConnStr: "localhost:6379"}
+	client := createTestClient(t, opts, nil)
+	runTestsForClient(t, client)
+}

+ 133 - 0
pkg/infra/remotecache/remotecache.go

@@ -0,0 +1,133 @@
+package remotecache
+
+import (
+	"bytes"
+	"context"
+	"encoding/gob"
+	"errors"
+	"time"
+
+	"github.com/grafana/grafana/pkg/setting"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+
+	"github.com/grafana/grafana/pkg/registry"
+)
+
+var (
+	// ErrCacheItemNotFound is returned if cache does not exist
+	ErrCacheItemNotFound = errors.New("cache item not found")
+
+	// ErrInvalidCacheType is returned if the type is invalid
+	ErrInvalidCacheType = errors.New("invalid remote cache name")
+
+	defaultMaxCacheExpiration = time.Hour * 24
+)
+
+func init() {
+	registry.RegisterService(&RemoteCache{})
+}
+
+// CacheStorage allows the caller to set, get and delete items in the cache.
+// Cached items are stored as byte arrays and marshalled using "encoding/gob"
+// so any struct added to the cache needs to be registred with `remotecache.Register`
+// ex `remotecache.Register(CacheableStruct{})``
+type CacheStorage interface {
+	// Get reads object from Cache
+	Get(key string) (interface{}, error)
+
+	// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
+	Set(key string, value interface{}, expire time.Duration) error
+
+	// Delete object from cache
+	Delete(key string) error
+}
+
+// RemoteCache allows Grafana to cache data outside its own process
+type RemoteCache struct {
+	log      log.Logger
+	client   CacheStorage
+	SQLStore *sqlstore.SqlStore `inject:""`
+	Cfg      *setting.Cfg       `inject:""`
+}
+
+// Get reads object from Cache
+func (ds *RemoteCache) Get(key string) (interface{}, error) {
+	return ds.client.Get(key)
+}
+
+// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
+func (ds *RemoteCache) Set(key string, value interface{}, expire time.Duration) error {
+	if expire == 0 {
+		expire = defaultMaxCacheExpiration
+	}
+
+	return ds.client.Set(key, value, expire)
+}
+
+// Delete object from cache
+func (ds *RemoteCache) Delete(key string) error {
+	return ds.client.Delete(key)
+}
+
+// Init initializes the service
+func (ds *RemoteCache) Init() error {
+	ds.log = log.New("cache.remote")
+	var err error
+	ds.client, err = createClient(ds.Cfg.RemoteCacheOptions, ds.SQLStore)
+	return err
+}
+
+// Run start the backend processes for cache clients
+func (ds *RemoteCache) Run(ctx context.Context) error {
+	//create new interface if more clients need GC jobs
+	backgroundjob, ok := ds.client.(registry.BackgroundService)
+	if ok {
+		return backgroundjob.Run(ctx)
+	}
+
+	<-ctx.Done()
+	return ctx.Err()
+}
+
+func createClient(opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) (CacheStorage, error) {
+	if opts.Name == redisCacheType {
+		return newRedisStorage(opts), nil
+	}
+
+	if opts.Name == memcachedCacheType {
+		return newMemcachedStorage(opts), nil
+	}
+
+	if opts.Name == databaseCacheType {
+		return newDatabaseCache(sqlstore), nil
+	}
+
+	return nil, ErrInvalidCacheType
+}
+
+// Register records a type, identified by a value for that type, under its
+// internal type name. That name will identify the concrete type of a value
+// sent or received as an interface variable. Only types that will be
+// transferred as implementations of interface values need to be registered.
+// Expecting to be used only during initialization, it panics if the mapping
+// between types and names is not a bijection.
+func Register(value interface{}) {
+	gob.Register(value)
+}
+
+type cachedItem struct {
+	Val interface{}
+}
+
+func encodeGob(item *cachedItem) ([]byte, error) {
+	buf := bytes.NewBuffer(nil)
+	err := gob.NewEncoder(buf).Encode(item)
+	return buf.Bytes(), err
+}
+
+func decodeGob(data []byte, out *cachedItem) error {
+	buf := bytes.NewBuffer(data)
+	return gob.NewDecoder(buf).Decode(&out)
+}

+ 93 - 0
pkg/infra/remotecache/remotecache_test.go

@@ -0,0 +1,93 @@
+package remotecache
+
+import (
+	"testing"
+	"time"
+
+	"github.com/bmizerany/assert"
+
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+type CacheableStruct struct {
+	String string
+	Int64  int64
+}
+
+func init() {
+	Register(CacheableStruct{})
+}
+
+func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) CacheStorage {
+	t.Helper()
+
+	dc := &RemoteCache{
+		SQLStore: sqlstore,
+		Cfg: &setting.Cfg{
+			RemoteCacheOptions: opts,
+		},
+	}
+
+	err := dc.Init()
+	if err != nil {
+		t.Fatalf("failed to init client for test. error: %v", err)
+	}
+
+	return dc
+}
+
+func TestCachedBasedOnConfig(t *testing.T) {
+
+	cfg := setting.NewCfg()
+	cfg.Load(&setting.CommandLineArgs{
+		HomePath: "../../../",
+	})
+
+	client := createTestClient(t, cfg.RemoteCacheOptions, sqlstore.InitTestDB(t))
+	runTestsForClient(t, client)
+}
+
+func TestInvalidCacheTypeReturnsError(t *testing.T) {
+	_, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil)
+	assert.Equal(t, err, ErrInvalidCacheType)
+}
+
+func runTestsForClient(t *testing.T, client CacheStorage) {
+	canPutGetAndDeleteCachedObjects(t, client)
+	canNotFetchExpiredItems(t, client)
+}
+
+func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) {
+	cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
+
+	err := client.Set("key1", cacheableStruct, 0)
+	assert.Equal(t, err, nil, "expected nil. got: ", err)
+
+	data, err := client.Get("key1")
+	s, ok := data.(CacheableStruct)
+
+	assert.Equal(t, ok, true)
+	assert.Equal(t, s.String, "hej")
+	assert.Equal(t, s.Int64, int64(2000))
+
+	err = client.Delete("key1")
+	assert.Equal(t, err, nil)
+
+	_, err = client.Get("key1")
+	assert.Equal(t, err, ErrCacheItemNotFound)
+}
+
+func canNotFetchExpiredItems(t *testing.T, client CacheStorage) {
+	cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
+
+	err := client.Set("key1", cacheableStruct, time.Second)
+	assert.Equal(t, err, nil)
+
+	//not sure how this can be avoided when testing redis/memcached :/
+	<-time.After(time.Second + time.Millisecond)
+
+	// should not be able to read that value since its expired
+	_, err = client.Get("key1")
+	assert.Equal(t, err, ErrCacheItemNotFound)
+}

+ 7 - 60
pkg/middleware/middleware_test.go

@@ -11,6 +11,7 @@ import (
 	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/auth"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
@@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) {
 				return nil
 			})
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 					UserId:        12,
 					UnhashedToken: unhashedToken,
@@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) {
 				return nil
 			})
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 					UserId:        12,
 					UnhashedToken: "",
 				}, nil
 			}
 
-			sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
+			sc.userAuthTokenService.TryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
 				userToken.UnhashedToken = "rotated"
 				return true, nil
 			}
@@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) {
 		middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
 			sc.withTokenSessionCookie("token")
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return nil, m.ErrUserTokenNotFound
 			}
 
@@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 		}))
 
 		session.Init(&msession.Options{}, 0)
-		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
 		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		session.StartSessionGC = func() {}
@@ -595,7 +596,7 @@ type scenarioContext struct {
 	handlerFunc          handlerFunc
 	defaultHandler       macaron.Handler
 	url                  string
-	userAuthTokenService *fakeUserAuthTokenService
+	userAuthTokenService *auth.FakeUserAuthTokenService
 
 	req *http.Request
 }
@@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() {
 
 type scenarioFunc func(c *scenarioContext)
 type handlerFunc func(c *m.ReqContext)
-
-type fakeUserAuthTokenService struct {
-	createTokenProvider    func(userId int64, clientIP, userAgent string) (*m.UserToken, error)
-	tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error)
-	lookupTokenProvider    func(unhashedToken string) (*m.UserToken, error)
-	revokeTokenProvider    func(token *m.UserToken) error
-	activeAuthTokenCount   func() (int64, error)
-}
-
-func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
-	return &fakeUserAuthTokenService{
-		createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
-			return &m.UserToken{
-				UserId:        0,
-				UnhashedToken: "",
-			}, nil
-		},
-		tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) {
-			return false, nil
-		},
-		lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) {
-			return &m.UserToken{
-				UserId:        0,
-				UnhashedToken: "",
-			}, nil
-		},
-		revokeTokenProvider: func(token *m.UserToken) error {
-			return nil
-		},
-		activeAuthTokenCount: func() (int64, error) {
-			return 10, nil
-		},
-	}
-}
-
-func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
-	return s.createTokenProvider(userId, clientIP, userAgent)
-}
-
-func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
-	return s.lookupTokenProvider(unhashedToken)
-}
-
-func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) {
-	return s.tryRotateTokenProvider(token, clientIP, userAgent)
-}
-
-func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error {
-	return s.revokeTokenProvider(token)
-}
-
-func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
-	return s.activeAuthTokenCount()
-}

+ 2 - 2
pkg/middleware/org_redirect_test.go

@@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 				return nil
 			})
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 					UserId:        0,
 					UnhashedToken: "",
@@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 				return nil
 			})
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 					UserId:        12,
 					UnhashedToken: "",

+ 3 - 2
pkg/middleware/quota_test.go

@@ -3,6 +3,7 @@ package middleware
 import (
 	"testing"
 
+	"github.com/grafana/grafana/pkg/services/auth"
 	"github.com/grafana/grafana/pkg/services/quota"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -36,7 +37,7 @@ func TestMiddlewareQuota(t *testing.T) {
 			},
 		}
 
-		fakeAuthTokenService := newFakeUserAuthTokenService()
+		fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
 		qs := &quota.QuotaService{
 			AuthTokenService: fakeAuthTokenService,
 		}
@@ -87,7 +88,7 @@ func TestMiddlewareQuota(t *testing.T) {
 				return nil
 			})
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 					UserId:        12,
 					UnhashedToken: "",

+ 2 - 1
pkg/middleware/recovery_test.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"github.com/grafana/grafana/pkg/setting"
 	. "github.com/smartystreets/goconvey/convey"
 	macaron "gopkg.in/macaron.v1"
@@ -62,7 +63,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 		}))
 
-		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
 		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		sc.m.Use(OrgRedirect())

+ 10 - 1
pkg/models/user_token.go

@@ -1,6 +1,8 @@
 package models
 
-import "errors"
+import (
+	"errors"
+)
 
 // Typed errors
 var (
@@ -23,11 +25,18 @@ type UserToken struct {
 	UnhashedToken string
 }
 
+type RevokeAuthTokenCmd struct {
+	AuthTokenId int64 `json:"authTokenId"`
+}
+
 // UserTokenService are used for generating and validating user tokens
 type UserTokenService interface {
 	CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
 	LookupToken(unhashedToken string) (*UserToken, error)
 	TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
 	RevokeToken(token *UserToken) error
+	RevokeAllUserTokens(userId int64) error
 	ActiveTokenCount() (int64, error)
+	GetUserToken(userId, userTokenId int64) (*UserToken, error)
+	GetUserTokens(userId int64) ([]*UserToken, error)
 }

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

@@ -221,6 +221,57 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error {
 	return nil
 }
 
+func (s *UserAuthTokenService) RevokeAllUserTokens(userId int64) error {
+	sql := `DELETE from user_auth_token WHERE user_id = ?`
+	res, err := s.SQLStore.NewSession().Exec(sql, userId)
+	if err != nil {
+		return err
+	}
+
+	affected, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+
+	s.log.Debug("all user tokens for user revoked", "userId", userId, "count", affected)
+
+	return nil
+}
+
+func (s *UserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
+	var token userAuthToken
+	exists, err := s.SQLStore.NewSession().Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token)
+	if err != nil {
+		return nil, err
+	}
+
+	if !exists {
+		return nil, models.ErrUserTokenNotFound
+	}
+
+	var result models.UserToken
+	token.toUserToken(&result)
+
+	return &result, nil
+}
+
+func (s *UserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
+	var tokens []*userAuthToken
+	err := s.SQLStore.NewSession().Where("user_id = ? AND created_at > ? AND rotated_at > ?", userId, s.createdAfterParam(), s.rotatedAfterParam()).Find(&tokens)
+	if err != nil {
+		return nil, err
+	}
+
+	result := []*models.UserToken{}
+	for _, token := range tokens {
+		var userToken models.UserToken
+		token.toUserToken(&userToken)
+		result = append(result, &userToken)
+	}
+
+	return result, nil
+}
+
 func (s *UserAuthTokenService) createdAfterParam() int64 {
 	tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
 	return getTime().Add(-tokenMaxLifetime).Unix()

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

@@ -75,6 +75,47 @@ func TestUserAuthToken(t *testing.T) {
 				err = userAuthTokenService.RevokeToken(userToken)
 				So(err, ShouldEqual, models.ErrUserTokenNotFound)
 			})
+
+			Convey("When creating an additional token", func() {
+				userToken2, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+				So(err, ShouldBeNil)
+				So(userToken2, ShouldNotBeNil)
+
+				Convey("Can get first user token", func() {
+					token, err := userAuthTokenService.GetUserToken(userID, userToken.Id)
+					So(err, ShouldBeNil)
+					So(token, ShouldNotBeNil)
+					So(token.Id, ShouldEqual, userToken.Id)
+				})
+
+				Convey("Can get second user token", func() {
+					token, err := userAuthTokenService.GetUserToken(userID, userToken2.Id)
+					So(err, ShouldBeNil)
+					So(token, ShouldNotBeNil)
+					So(token.Id, ShouldEqual, userToken2.Id)
+				})
+
+				Convey("Can get user tokens", func() {
+					tokens, err := userAuthTokenService.GetUserTokens(userID)
+					So(err, ShouldBeNil)
+					So(tokens, ShouldHaveLength, 2)
+					So(tokens[0].Id, ShouldEqual, userToken.Id)
+					So(tokens[1].Id, ShouldEqual, userToken2.Id)
+				})
+
+				Convey("Can revoke all user tokens", func() {
+					err := userAuthTokenService.RevokeAllUserTokens(userID)
+					So(err, ShouldBeNil)
+
+					model, err := ctx.getAuthTokenByID(userToken.Id)
+					So(err, ShouldBeNil)
+					So(model, ShouldBeNil)
+
+					model2, err := ctx.getAuthTokenByID(userToken2.Id)
+					So(err, ShouldBeNil)
+					So(model2, ShouldBeNil)
+				})
+			})
 		})
 
 		Convey("expires correctly", func() {

+ 81 - 0
pkg/services/auth/testing.go

@@ -0,0 +1,81 @@
+package auth
+
+import "github.com/grafana/grafana/pkg/models"
+
+type FakeUserAuthTokenService struct {
+	CreateTokenProvider         func(userId int64, clientIP, userAgent string) (*models.UserToken, error)
+	TryRotateTokenProvider      func(token *models.UserToken, clientIP, userAgent string) (bool, error)
+	LookupTokenProvider         func(unhashedToken string) (*models.UserToken, error)
+	RevokeTokenProvider         func(token *models.UserToken) error
+	RevokeAllUserTokensProvider func(userId int64) error
+	ActiveAuthTokenCount        func() (int64, error)
+	GetUserTokenProvider        func(userId, userTokenId int64) (*models.UserToken, error)
+	GetUserTokensProvider       func(userId int64) ([]*models.UserToken, error)
+}
+
+func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
+	return &FakeUserAuthTokenService{
+		CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
+			return &models.UserToken{
+				UserId:        0,
+				UnhashedToken: "",
+			}, nil
+		},
+		TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) {
+			return false, nil
+		},
+		LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) {
+			return &models.UserToken{
+				UserId:        0,
+				UnhashedToken: "",
+			}, nil
+		},
+		RevokeTokenProvider: func(token *models.UserToken) error {
+			return nil
+		},
+		RevokeAllUserTokensProvider: func(userId int64) error {
+			return nil
+		},
+		ActiveAuthTokenCount: func() (int64, error) {
+			return 10, nil
+		},
+		GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) {
+			return nil, nil
+		},
+		GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) {
+			return nil, nil
+		},
+	}
+}
+
+func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
+	return s.CreateTokenProvider(userId, clientIP, userAgent)
+}
+
+func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
+	return s.LookupTokenProvider(unhashedToken)
+}
+
+func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
+	return s.TryRotateTokenProvider(token, clientIP, userAgent)
+}
+
+func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error {
+	return s.RevokeTokenProvider(token)
+}
+
+func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error {
+	return s.RevokeAllUserTokensProvider(userId)
+}
+
+func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
+	return s.ActiveAuthTokenCount()
+}
+
+func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
+	return s.GetUserTokenProvider(userId, userTokenId)
+}
+
+func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
+	return s.GetUserTokensProvider(userId)
+}

+ 22 - 0
pkg/services/sqlstore/migrations/cache_data_mig.go

@@ -0,0 +1,22 @@
+package migrations
+
+import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+
+func addCacheMigration(mg *migrator.Migrator) {
+	var cacheDataV1 = migrator.Table{
+		Name: "cache_data",
+		Columns: []*migrator.Column{
+			{Name: "cache_key", Type: migrator.DB_NVarchar, IsPrimaryKey: true, Length: 168},
+			{Name: "data", Type: migrator.DB_Blob},
+			{Name: "expires", Type: migrator.DB_Integer, Length: 255, Nullable: false},
+			{Name: "created_at", Type: migrator.DB_Integer, Length: 255, Nullable: false},
+		},
+		Indices: []*migrator.Index{
+			{Cols: []string{"cache_key"}, Type: migrator.UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("create cache_data table", migrator.NewAddTableMigration(cacheDataV1))
+
+	mg.AddMigration("add unique index cache_data.cache_key", migrator.NewAddIndexMigration(cacheDataV1, cacheDataV1.Indices[0]))
+}

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

@@ -33,6 +33,7 @@ func AddMigrations(mg *Migrator) {
 	addUserAuthMigrations(mg)
 	addServerlockMigrations(mg)
 	addUserAuthTokenMigrations(mg)
+	addCacheMigration(mg)
 }
 
 func addMigrationLogMigrations(mg *Migrator) {

+ 18 - 0
pkg/setting/setting.go

@@ -241,6 +241,12 @@ type Cfg struct {
 
 	// User
 	EditorsCanOwn bool
+
+	// Dataproxy
+	SendUserHeader bool
+
+	// DistributedCache
+	RemoteCacheOptions *RemoteCacheOptions
 }
 
 type CommandLineArgs struct {
@@ -601,6 +607,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	dataproxy := iniFile.Section("dataproxy")
 	DataProxyLogging = dataproxy.Key("logging").MustBool(false)
 	DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
+	cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false)
 
 	// read security settings
 	security := iniFile.Section("security")
@@ -781,9 +788,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	enterprise := iniFile.Section("enterprise")
 	cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
 
+	cacheServer := iniFile.Section("remote_cache")
+	cfg.RemoteCacheOptions = &RemoteCacheOptions{
+		Name:    cacheServer.Key("type").MustString("database"),
+		ConnStr: cacheServer.Key("connstr").MustString(""),
+	}
+
 	return nil
 }
 
+type RemoteCacheOptions struct {
+	Name    string
+	ConnStr string
+}
+
 func (cfg *Cfg) readSessionConfig() {
 	sec := cfg.Raw.Section("session")
 	SessionOptions = session.Options{}

+ 4 - 1
pkg/tsdb/mssql/mssql.go

@@ -3,6 +3,7 @@ package mssql
 import (
 	"database/sql"
 	"fmt"
+	"github.com/grafana/grafana/pkg/setting"
 	"strconv"
 
 	_ "github.com/denisenkom/go-mssqldb"
@@ -24,7 +25,9 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
 	if err != nil {
 		return nil, err
 	}
-	logger.Debug("getEngine", "connection", cnnstr)
+	if setting.Env == setting.DEV {
+		logger.Debug("getEngine", "connection", cnnstr)
+	}
 
 	config := tsdb.SqlQueryEndpointConfiguration{
 		DriverName:        "mssql",

+ 4 - 1
pkg/tsdb/mysql/mysql.go

@@ -3,6 +3,7 @@ package mysql
 import (
 	"database/sql"
 	"fmt"
+	"github.com/grafana/grafana/pkg/setting"
 	"reflect"
 	"strconv"
 	"strings"
@@ -44,7 +45,9 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
 		cnnstr += "&tls=" + tlsConfigString
 	}
 
-	logger.Debug("getEngine", "connection", cnnstr)
+	if setting.Env == setting.DEV {
+		logger.Debug("getEngine", "connection", cnnstr)
+	}
 
 	config := tsdb.SqlQueryEndpointConfiguration{
 		DriverName:        "mysql",

+ 4 - 1
pkg/tsdb/postgres/postgres.go

@@ -2,6 +2,7 @@ package postgres
 
 import (
 	"database/sql"
+	"github.com/grafana/grafana/pkg/setting"
 	"net/url"
 	"strconv"
 
@@ -19,7 +20,9 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp
 	logger := log.New("tsdb.postgres")
 
 	cnnstr := generateConnectionString(datasource)
-	logger.Debug("getEngine", "connection", cnnstr)
+	if setting.Env == setting.DEV {
+		logger.Debug("getEngine", "connection", cnnstr)
+	}
 
 	config := tsdb.SqlQueryEndpointConfiguration{
 		DriverName:        "postgres",

+ 69 - 1
pkg/tsdb/testdata/scenarios.go

@@ -2,6 +2,7 @@ package testdata
 
 import (
 	"encoding/json"
+	"math"
 	"math/rand"
 	"strconv"
 	"strings"
@@ -100,6 +101,15 @@ func init() {
 		},
 	})
 
+	registerScenario(&Scenario{
+		Id:   "random_walk_table",
+		Name: "Random Walk Table",
+
+		Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+			return getRandomWalkTable(query, context)
+		},
+	})
+
 	registerScenario(&Scenario{
 		Id:          "slow_query",
 		Name:        "Slow Query",
@@ -251,7 +261,7 @@ func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResu
 	series := newSeriesForQuery(query)
 
 	points := make(tsdb.TimeSeriesPoints, 0)
-	walker := rand.Float64() * 100
+	walker := query.Model.Get("startValue").MustFloat64(rand.Float64() * 100)
 
 	for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
 		points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
@@ -267,6 +277,64 @@ func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResu
 	return queryRes
 }
 
+func getRandomWalkTable(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
+	timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
+	to := tsdbQuery.TimeRange.GetToAsMsEpoch()
+
+	table := tsdb.Table{
+		Columns: []tsdb.TableColumn{
+			{Text: "Time"},
+			{Text: "Value"},
+			{Text: "Min"},
+			{Text: "Max"},
+			{Text: "Info"},
+		},
+		Rows: []tsdb.RowValues{},
+	}
+
+	withNil := query.Model.Get("withNil").MustBool(false)
+	walker := query.Model.Get("startValue").MustFloat64(rand.Float64() * 100)
+	spread := 2.5
+	var info strings.Builder
+
+	for i := int64(0); i < query.MaxDataPoints && timeWalkerMs < to; i++ {
+		delta := rand.Float64() - 0.5
+		walker += delta
+
+		info.Reset()
+		if delta > 0 {
+			info.WriteString("up")
+		} else {
+			info.WriteString("down")
+		}
+		if math.Abs(delta) > .4 {
+			info.WriteString(" fast")
+		}
+		row := tsdb.RowValues{
+			float64(timeWalkerMs),
+			walker,
+			walker - ((rand.Float64() * spread) + 0.01), // Min
+			walker + ((rand.Float64() * spread) + 0.01), // Max
+			info.String(),
+		}
+
+		// Add some random null values
+		if withNil && rand.Float64() > 0.8 {
+			for i := 1; i < 4; i++ {
+				if rand.Float64() > .2 {
+					row[i] = nil
+				}
+			}
+		}
+
+		table.Rows = append(table.Rows, row)
+		timeWalkerMs += query.IntervalMs
+	}
+	queryRes := tsdb.NewQueryResult()
+	queryRes.Tables = append(queryRes.Tables, &table)
+	return queryRes
+}
+
 func registerScenario(scenario *Scenario) {
 	ScenarioRegistry[scenario.Id] = scenario
 }

+ 94 - 0
pkg/tsdb/testdata/scenarios_test.go

@@ -0,0 +1,94 @@
+package testdata
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/tsdb"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestTestdataScenarios(t *testing.T) {
+	Convey("random walk ", t, func() {
+		scenario, _ := ScenarioRegistry["random_walk"]
+
+		Convey("Should start at the requested value", func() {
+			req := &tsdb.TsdbQuery{
+				TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
+				Queries: []*tsdb.Query{
+					{RefId: "A", IntervalMs: 100, MaxDataPoints: 100, Model: simplejson.New()},
+				},
+			}
+			query := req.Queries[0]
+			query.Model.Set("startValue", 1.234)
+
+			result := scenario.Handler(req.Queries[0], req)
+			points := result.Series[0].Points
+
+			So(result.Series, ShouldNotBeNil)
+			So(points[0][0].Float64, ShouldEqual, 1.234)
+		})
+	})
+
+	Convey("random walk table", t, func() {
+		scenario, _ := ScenarioRegistry["random_walk_table"]
+
+		Convey("Should return a table that looks like value/min/max", func() {
+			req := &tsdb.TsdbQuery{
+				TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
+				Queries: []*tsdb.Query{
+					{RefId: "A", IntervalMs: 100, MaxDataPoints: 100, Model: simplejson.New()},
+				},
+			}
+
+			result := scenario.Handler(req.Queries[0], req)
+			table := result.Tables[0]
+
+			So(len(table.Rows), ShouldBeGreaterThan, 50)
+			for _, row := range table.Rows {
+				value := row[1]
+				min := row[2]
+				max := row[3]
+
+				So(min, ShouldBeLessThan, value)
+				So(max, ShouldBeGreaterThan, value)
+			}
+		})
+
+		Convey("Should return a table with some nil values", func() {
+			req := &tsdb.TsdbQuery{
+				TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
+				Queries: []*tsdb.Query{
+					{RefId: "A", IntervalMs: 100, MaxDataPoints: 100, Model: simplejson.New()},
+				},
+			}
+			query := req.Queries[0]
+			query.Model.Set("withNil", true)
+
+			result := scenario.Handler(req.Queries[0], req)
+			table := result.Tables[0]
+
+			nil1 := false
+			nil2 := false
+			nil3 := false
+
+			So(len(table.Rows), ShouldBeGreaterThan, 50)
+			for _, row := range table.Rows {
+				if row[1] == nil {
+					nil1 = true
+				}
+				if row[2] == nil {
+					nil2 = true
+				}
+				if row[3] == nil {
+					nil3 = true
+				}
+			}
+
+			So(nil1, ShouldBeTrue)
+			So(nil2, ShouldBeTrue)
+			So(nil3, ShouldBeTrue)
+		})
+	})
+}

+ 48 - 38
public/app/app.ts

@@ -17,12 +17,13 @@ import 'vendor/angular-other/angular-strap';
 import $ from 'jquery';
 import angular from 'angular';
 import config from 'app/core/config';
+// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move
 import _ from 'lodash';
 import moment from 'moment';
 import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar';
 
 // add move to lodash for backward compatabiltiy
-_.move = (array, fromIndex, toIndex) => {
+_.move = (array: [], fromIndex: number, toIndex: number) => {
   array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
   return array;
 };
@@ -36,7 +37,7 @@ import 'app/features/all';
 
 // import symlinked extensions
 const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
-extensionsIndex.keys().forEach(key => {
+extensionsIndex.keys().forEach((key: any) => {
   extensionsIndex(key);
 });
 
@@ -52,7 +53,7 @@ export class GrafanaApp {
     this.ngModuleDependencies = [];
   }
 
-  useModule(module) {
+  useModule(module: angular.IModule) {
     if (this.preBootModules) {
       this.preBootModules.push(module);
     } else {
@@ -67,40 +68,49 @@ export class GrafanaApp {
 
     moment.locale(config.bootData.user.locale);
 
-    app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => {
-      // pre assing bindings before constructor calls
-      $compileProvider.preAssignBindingsEnabled(true);
-
-      if (config.buildInfo.env !== 'development') {
-        $compileProvider.debugInfoEnabled(false);
-      }
-
-      $httpProvider.useApplyAsync(true);
-
-      this.registerFunctions.controller = $controllerProvider.register;
-      this.registerFunctions.directive = $compileProvider.directive;
-      this.registerFunctions.factory = $provide.factory;
-      this.registerFunctions.service = $provide.service;
-      this.registerFunctions.filter = $filterProvider.register;
-
-      $provide.decorator('$http', [
-        '$delegate',
-        '$templateCache',
-        ($delegate, $templateCache) => {
-          const get = $delegate.get;
-          $delegate.get = (url, config) => {
-            if (url.match(/\.html$/)) {
-              // some template's already exist in the cache
-              if (!$templateCache.get(url)) {
-                url += '?v=' + new Date().getTime();
+    app.config(
+      (
+        $locationProvider: angular.ILocationProvider,
+        $controllerProvider: angular.IControllerProvider,
+        $compileProvider: angular.ICompileProvider,
+        $filterProvider: angular.IFilterProvider,
+        $httpProvider: angular.IHttpProvider,
+        $provide: angular.auto.IProvideService
+      ) => {
+        // pre assing bindings before constructor calls
+        $compileProvider.preAssignBindingsEnabled(true);
+
+        if (config.buildInfo.env !== 'development') {
+          $compileProvider.debugInfoEnabled(false);
+        }
+
+        $httpProvider.useApplyAsync(true);
+
+        this.registerFunctions.controller = $controllerProvider.register;
+        this.registerFunctions.directive = $compileProvider.directive;
+        this.registerFunctions.factory = $provide.factory;
+        this.registerFunctions.service = $provide.service;
+        this.registerFunctions.filter = $filterProvider.register;
+
+        $provide.decorator('$http', [
+          '$delegate',
+          '$templateCache',
+          ($delegate: any, $templateCache: any) => {
+            const get = $delegate.get;
+            $delegate.get = (url: string, config: any) => {
+              if (url.match(/\.html$/)) {
+                // some template's already exist in the cache
+                if (!$templateCache.get(url)) {
+                  url += '?v=' + new Date().getTime();
+                }
               }
-            }
-            return get(url, config);
-          };
-          return $delegate;
-        },
-      ]);
-    });
+              return get(url, config);
+            };
+            return $delegate;
+          },
+        ]);
+      }
+    );
 
     this.ngModuleDependencies = [
       'grafana.core',
@@ -116,7 +126,7 @@ export class GrafanaApp {
     ];
 
     // makes it possible to add dynamic stuff
-    _.each(angularModules, m => {
+    _.each(angularModules, (m: angular.IModule) => {
       this.useModule(m);
     });
 
@@ -129,7 +139,7 @@ export class GrafanaApp {
 
     // bootstrap the app
     angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
-      _.each(this.preBootModules, module => {
+      _.each(this.preBootModules, (module: angular.IModule) => {
         _.extend(module, this.registerFunctions);
       });
 

+ 0 - 1
public/app/core/components/Form/index.ts

@@ -1 +0,0 @@
-export { Input } from './Input';

+ 48 - 20
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -1,7 +1,30 @@
+// @ts-ignore
 import _ from 'lodash';
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
 import { SearchSrv } from 'app/core/services/search_srv';
+import { BackendSrv } from 'app/core/services/backend_srv';
+import { NavModelSrv } from 'app/core/nav_model_srv';
+import { ContextSrv } from 'app/core/services/context_srv';
+
+export interface Section {
+  id: number;
+  uid: string;
+  title: string;
+  expanded: false;
+  items: any[];
+  url: string;
+  icon: string;
+  score: number;
+  checked: boolean;
+  hideHeader: boolean;
+  toggle: Function;
+}
+
+export interface FoldersAndDashboardUids {
+  folderUids: string[];
+  dashboardUids: string[];
+}
 
 class Query {
   query: string;
@@ -14,7 +37,7 @@ class Query {
 }
 
 export class ManageDashboardsCtrl {
-  sections: any[];
+  sections: Section[];
 
   query: Query;
   navModel: any;
@@ -45,7 +68,12 @@ export class ManageDashboardsCtrl {
   hasEditPermissionInFolders: boolean;
 
   /** @ngInject */
-  constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
+  constructor(
+    private backendSrv: BackendSrv,
+    navModelSrv: NavModelSrv,
+    private searchSrv: SearchSrv,
+    private contextSrv: ContextSrv
+  ) {
     this.isEditor = this.contextSrv.isEditor;
     this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders;
 
@@ -73,7 +101,7 @@ export class ManageDashboardsCtrl {
   refreshList() {
     return this.searchSrv
       .search(this.query)
-      .then(result => {
+      .then((result: Section[]) => {
         return this.initDashboardList(result);
       })
       .then(() => {
@@ -81,7 +109,7 @@ export class ManageDashboardsCtrl {
           return;
         }
 
-        return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
+        return this.backendSrv.getFolderByUid(this.folderUid).then((folder: any) => {
           this.canSave = folder.canSave;
           if (!this.canSave) {
             this.hasEditPermissionInFolders = false;
@@ -90,7 +118,7 @@ export class ManageDashboardsCtrl {
       });
   }
 
-  initDashboardList(result: any) {
+  initDashboardList(result: Section[]) {
     this.canMove = false;
     this.canDelete = false;
     this.selectAllChecked = false;
@@ -128,25 +156,25 @@ export class ManageDashboardsCtrl {
     this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
   }
 
-  getFoldersAndDashboardsToDelete() {
-    const selectedDashboards = {
-      folders: [],
-      dashboards: [],
+  getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids {
+    const selectedDashboards: FoldersAndDashboardUids = {
+      folderUids: [],
+      dashboardUids: [],
     };
 
     for (const section of this.sections) {
       if (section.checked && section.id !== 0) {
-        selectedDashboards.folders.push(section.uid);
+        selectedDashboards.folderUids.push(section.uid);
       } else {
         const selected = _.filter(section.items, { checked: true });
-        selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
+        selectedDashboards.dashboardUids.push(..._.map(selected, 'uid'));
       }
     }
 
     return selectedDashboards;
   }
 
-  getFolderIds(sections) {
+  getFolderIds(sections: Section[]) {
     const ids = [];
     for (const s of sections) {
       if (s.checked) {
@@ -158,8 +186,8 @@ export class ManageDashboardsCtrl {
 
   delete() {
     const data = this.getFoldersAndDashboardsToDelete();
-    const folderCount = data.folders.length;
-    const dashCount = data.dashboards.length;
+    const folderCount = data.folderUids.length;
+    const dashCount = data.dashboardUids.length;
     let text = 'Do you want to delete the ';
     let text2;
 
@@ -179,12 +207,12 @@ export class ManageDashboardsCtrl {
       icon: 'fa-trash',
       yesText: 'Delete',
       onConfirm: () => {
-        this.deleteFoldersAndDashboards(data.folders, data.dashboards);
+        this.deleteFoldersAndDashboards(data.folderUids, data.dashboardUids);
       },
     });
   }
 
-  private deleteFoldersAndDashboards(folderUids, dashboardUids) {
+  private deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
     this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
       this.refreshList();
     });
@@ -219,13 +247,13 @@ export class ManageDashboardsCtrl {
   }
 
   initTagFilter() {
-    return this.searchSrv.getDashboardTags().then(results => {
+    return this.searchSrv.getDashboardTags().then((results: any) => {
       this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
       this.selectedTagFilter = this.tagFilterOptions[0];
     });
   }
 
-  filterByTag(tag) {
+  filterByTag(tag: any) {
     if (_.indexOf(this.query.tag, tag) === -1) {
       this.query.tag.push(tag);
     }
@@ -243,7 +271,7 @@ export class ManageDashboardsCtrl {
     return res;
   }
 
-  removeTag(tag, evt) {
+  removeTag(tag: any, evt: Event) {
     this.query.tag = _.without(this.query.tag, tag);
     this.refreshList();
     if (evt) {
@@ -269,7 +297,7 @@ export class ManageDashboardsCtrl {
         section.checked = this.selectAllChecked;
       }
 
-      section.items = _.map(section.items, item => {
+      section.items = _.map(section.items, (item: any) => {
         item.checked = this.selectAllChecked;
         return item;
       });

+ 1 - 1
public/app/core/config.ts

@@ -13,7 +13,7 @@ export interface BuildInfo {
 
 export class Settings {
   datasources: any;
-  panels: PanelPlugin[];
+  panels: { [key: string]: PanelPlugin };
   appSubUrl: string;
   windowTitlePrefix: string;
   buildInfo: BuildInfo;

+ 89 - 55
public/app/core/specs/manage_dashboards.test.ts

@@ -1,12 +1,39 @@
-import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards';
-import { SearchSrv } from 'app/core/services/search_srv';
+// @ts-ignore
 import q from 'q';
+import {
+  ManageDashboardsCtrl,
+  Section,
+  FoldersAndDashboardUids,
+} from 'app/core/components/manage_dashboards/manage_dashboards';
+import { SearchSrv } from 'app/core/services/search_srv';
+import { BackendSrv } from '../services/backend_srv';
+import { NavModelSrv } from '../nav_model_srv';
+import { ContextSrv } from '../services/context_srv';
+
+const mockSection = (overides?: object): Section => {
+  const defaultSection: Section = {
+    id: 0,
+    items: [],
+    checked: false,
+    expanded: false,
+    hideHeader: false,
+    icon: '',
+    score: 0,
+    title: 'Some Section',
+    toggle: jest.fn(),
+    uid: 'someuid',
+    url: '/some/url/',
+  };
+
+  return { ...defaultSection, ...overides };
+};
 
 describe('ManageDashboards', () => {
-  let ctrl;
+  let ctrl: ManageDashboardsCtrl;
 
   describe('when browsing dashboards', () => {
     beforeEach(() => {
+      const tags: any[] = [];
       const response = [
         {
           id: 410,
@@ -18,11 +45,11 @@ describe('ManageDashboards', () => {
               title: 'Dashboard Test',
               url: 'dashboard/db/dashboard-test',
               icon: 'fa fa-folder',
-              tags: [],
+              tags,
               isStarred: false,
             },
           ],
-          tags: [],
+          tags,
           isStarred: false,
         },
         {
@@ -37,11 +64,11 @@ describe('ManageDashboards', () => {
               title: 'Dashboard Test',
               url: 'dashboard/db/dashboard-test',
               icon: 'fa fa-folder',
-              tags: [],
+              tags,
               isStarred: false,
             },
           ],
-          tags: [],
+          tags,
           isStarred: false,
         },
       ];
@@ -61,6 +88,7 @@ describe('ManageDashboards', () => {
 
   describe('when browsing dashboards for a folder', () => {
     beforeEach(() => {
+      const tags: any[] = [];
       const response = [
         {
           id: 410,
@@ -72,11 +100,11 @@ describe('ManageDashboards', () => {
               title: 'Dashboard Test',
               url: 'dashboard/db/dashboard-test',
               icon: 'fa fa-folder',
-              tags: [],
+              tags,
               isStarred: false,
             },
           ],
-          tags: [],
+          tags,
           isStarred: false,
         },
       ];
@@ -92,6 +120,7 @@ describe('ManageDashboards', () => {
 
   describe('when searching dashboards', () => {
     beforeEach(() => {
+      const tags: any[] = [];
       const response = [
         {
           checked: false,
@@ -103,7 +132,7 @@ describe('ManageDashboards', () => {
               title: 'Dashboard Test',
               url: 'dashboard/db/dashboard-test',
               icon: 'fa fa-folder',
-              tags: [],
+              tags,
               isStarred: false,
               folderId: 410,
               folderUid: 'uid',
@@ -115,7 +144,7 @@ describe('ManageDashboards', () => {
               title: 'Dashboard Test',
               url: 'dashboard/db/dashboard-test',
               icon: 'fa fa-folder',
-              tags: [],
+              tags,
               folderId: 499,
               isStarred: false,
             },
@@ -245,7 +274,7 @@ describe('ManageDashboards', () => {
   });
 
   describe('when selecting dashboards', () => {
-    let ctrl;
+    let ctrl: ManageDashboardsCtrl;
 
     beforeEach(() => {
       ctrl = createCtrlWithStubs([]);
@@ -254,16 +283,16 @@ describe('ManageDashboards', () => {
     describe('and no dashboards are selected', () => {
       beforeEach(() => {
         ctrl.sections = [
-          {
+          mockSection({
             id: 1,
             items: [{ id: 2, checked: false }],
             checked: false,
-          },
-          {
+          }),
+          mockSection({
             id: 0,
             items: [{ id: 3, checked: false }],
             checked: false,
-          },
+          }),
         ];
         ctrl.selectionChanged();
       });
@@ -302,16 +331,16 @@ describe('ManageDashboards', () => {
     describe('and all folders and dashboards are selected', () => {
       beforeEach(() => {
         ctrl.sections = [
-          {
+          mockSection({
             id: 1,
             items: [{ id: 2, checked: true }],
             checked: true,
-          },
-          {
+          }),
+          mockSection({
             id: 0,
             items: [{ id: 3, checked: true }],
             checked: true,
-          },
+          }),
         ];
         ctrl.selectionChanged();
       });
@@ -350,18 +379,18 @@ describe('ManageDashboards', () => {
     describe('and one dashboard in root is selected', () => {
       beforeEach(() => {
         ctrl.sections = [
-          {
+          mockSection({
             id: 1,
             title: 'folder',
             items: [{ id: 2, checked: false }],
             checked: false,
-          },
-          {
+          }),
+          mockSection({
             id: 0,
             title: 'General',
             items: [{ id: 3, checked: true }],
             checked: false,
-          },
+          }),
         ];
         ctrl.selectionChanged();
       });
@@ -378,18 +407,18 @@ describe('ManageDashboards', () => {
     describe('and one child dashboard is selected', () => {
       beforeEach(() => {
         ctrl.sections = [
-          {
+          mockSection({
             id: 1,
             title: 'folder',
             items: [{ id: 2, checked: true }],
             checked: false,
-          },
-          {
+          }),
+          mockSection({
             id: 0,
             title: 'General',
             items: [{ id: 3, checked: false }],
             checked: false,
-          },
+          }),
         ];
 
         ctrl.selectionChanged();
@@ -407,18 +436,18 @@ describe('ManageDashboards', () => {
     describe('and one child dashboard and one dashboard is selected', () => {
       beforeEach(() => {
         ctrl.sections = [
-          {
+          mockSection({
             id: 1,
             title: 'folder',
             items: [{ id: 2, checked: true }],
             checked: false,
-          },
-          {
+          }),
+          mockSection({
             id: 0,
             title: 'General',
             items: [{ id: 3, checked: true }],
             checked: false,
-          },
+          }),
         ];
 
         ctrl.selectionChanged();
@@ -436,24 +465,24 @@ describe('ManageDashboards', () => {
     describe('and one child dashboard and one folder is selected', () => {
       beforeEach(() => {
         ctrl.sections = [
-          {
+          mockSection({
             id: 1,
             title: 'folder',
             items: [{ id: 2, checked: false }],
             checked: true,
-          },
-          {
+          }),
+          mockSection({
             id: 3,
             title: 'folder',
             items: [{ id: 4, checked: true }],
             checked: false,
-          },
-          {
+          }),
+          mockSection({
             id: 0,
             title: 'General',
             items: [{ id: 3, checked: false }],
             checked: false,
-          },
+          }),
         ];
 
         ctrl.selectionChanged();
@@ -470,55 +499,55 @@ describe('ManageDashboards', () => {
   });
 
   describe('when deleting dashboards', () => {
-    let toBeDeleted: any;
+    let toBeDeleted: FoldersAndDashboardUids;
 
     beforeEach(() => {
       ctrl = createCtrlWithStubs([]);
 
       ctrl.sections = [
-        {
+        mockSection({
           id: 1,
           uid: 'folder',
           title: 'folder',
           items: [{ id: 2, checked: true, uid: 'folder-dash' }],
           checked: true,
-        },
-        {
+        }),
+        mockSection({
           id: 3,
           title: 'folder-2',
           items: [{ id: 3, checked: true, uid: 'folder-2-dash' }],
           checked: false,
           uid: 'folder-2',
-        },
-        {
+        }),
+        mockSection({
           id: 0,
           title: 'General',
           items: [{ id: 3, checked: true, uid: 'root-dash' }],
           checked: true,
-        },
+        }),
       ];
 
       toBeDeleted = ctrl.getFoldersAndDashboardsToDelete();
     });
 
     it('should return 1 folder', () => {
-      expect(toBeDeleted.folders.length).toEqual(1);
+      expect(toBeDeleted.folderUids.length).toEqual(1);
     });
 
     it('should return 2 dashboards', () => {
-      expect(toBeDeleted.dashboards.length).toEqual(2);
+      expect(toBeDeleted.dashboardUids.length).toEqual(2);
     });
 
     it('should filter out children if parent is checked', () => {
-      expect(toBeDeleted.folders[0]).toEqual('folder');
+      expect(toBeDeleted.folderUids[0]).toEqual('folder');
     });
 
     it('should not filter out children if parent not is checked', () => {
-      expect(toBeDeleted.dashboards[0]).toEqual('folder-2-dash');
+      expect(toBeDeleted.dashboardUids[0]).toEqual('folder-2-dash');
     });
 
     it('should not filter out children if parent is checked and root', () => {
-      expect(toBeDeleted.dashboards[1]).toEqual('root-dash');
+      expect(toBeDeleted.dashboardUids[1]).toEqual('root-dash');
     });
   });
 
@@ -527,19 +556,19 @@ describe('ManageDashboards', () => {
       ctrl = createCtrlWithStubs([]);
 
       ctrl.sections = [
-        {
+        mockSection({
           id: 1,
           title: 'folder',
           items: [{ id: 2, checked: true, uid: 'dash' }],
           checked: false,
           uid: 'folder',
-        },
-        {
+        }),
+        mockSection({
           id: 0,
           title: 'General',
           items: [{ id: 3, checked: true, uid: 'dash-2' }],
           checked: false,
-        },
+        }),
       ];
     });
 
@@ -562,5 +591,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
     },
   };
 
-  return new ManageDashboardsCtrl({}, { getNav: () => {} }, searchSrvStub as SearchSrv, { isEditor: true });
+  return new ManageDashboardsCtrl(
+    {} as BackendSrv,
+    { getNav: () => {} } as NavModelSrv,
+    searchSrvStub as SearchSrv,
+    { isEditor: true } as ContextSrv
+  );
 }

+ 5 - 8
public/app/core/utils/explore.ts

@@ -9,6 +9,7 @@ import store from 'app/core/store';
 import { parse as parseDate } from 'app/core/utils/datemath';
 import { colors } from '@grafana/ui';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
+import { getNextRefIdChar } from './query';
 
 // Types
 import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
@@ -225,12 +226,8 @@ export function generateKey(index = 0): string {
   return `Q-${Date.now()}-${Math.random()}-${index}`;
 }
 
-export function generateRefId(index = 0): string {
-  return `${index + 1}`;
-}
-
-export function generateEmptyQuery(index = 0): { refId: string; key: string } {
-  return { refId: generateRefId(index), key: generateKey(index) };
+export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery {
+  return { refId: getNextRefIdChar(queries), key: generateKey(index) };
 }
 
 /**
@@ -238,9 +235,9 @@ export function generateEmptyQuery(index = 0): { refId: string; key: string } {
  */
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
   if (queries && typeof queries === 'object' && queries.length > 0) {
-    return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
+    return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(queries, i) }));
   }
-  return [{ ...generateEmptyQuery() }];
+  return [{ ...generateEmptyQuery(queries) }];
 }
 
 /**

+ 30 - 0
public/app/core/utils/query.test.ts

@@ -0,0 +1,30 @@
+import { DataQuery } from '@grafana/ui';
+import { getNextRefIdChar } from './query';
+
+const dataQueries: DataQuery[] = [
+  {
+    refId: 'A',
+  },
+  {
+    refId: 'B',
+  },
+  {
+    refId: 'C',
+  },
+  {
+    refId: 'D',
+  },
+  {
+    refId: 'E',
+  },
+];
+
+describe('Get next refId char', () => {
+  it('should return next char', () => {
+    expect(getNextRefIdChar(dataQueries)).toEqual('F');
+  });
+
+  it('should get first char', () => {
+    expect(getNextRefIdChar([])).toEqual('A');
+  });
+});

+ 12 - 0
public/app/core/utils/query.ts

@@ -0,0 +1,12 @@
+import _ from 'lodash';
+import { DataQuery } from '@grafana/ui/';
+
+export const getNextRefIdChar = (queries: DataQuery[]): string => {
+  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+  return _.find(letters, refId => {
+    return _.every(queries, other => {
+      return other.refId !== refId;
+    });
+  });
+};

+ 0 - 16
public/app/core/utils/validate.ts

@@ -1,16 +0,0 @@
-import { ValidationRule, ValidationEvents } from 'app/types';
-import { EventsWithValidation } from 'app/core/components/Form/Input';
-
-export const validate = (value: string, validationRules: ValidationRule[]) => {
-  const errors = validationRules.reduce((acc, currRule) => {
-    if (!currRule.rule(value)) {
-      return acc.concat(currRule.errorMessage);
-    }
-    return acc;
-  }, []);
-  return errors.length > 0 ? errors : null;
-};
-
-export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => {
-  return validationEvents && validationEvents[event];
-};

+ 10 - 7
public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts

@@ -4,13 +4,16 @@ jest.mock('app/core/store', () => {
   };
 });
 
+// @ts-ignore
 import _ from 'lodash';
 import config from 'app/core/config';
 import { DashboardExporter } from './DashboardExporter';
 import { DashboardModel } from '../../state/DashboardModel';
+import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { PanelPlugin } from 'app/types';
 
 describe('given dashboard with repeated panels', () => {
-  let dash, exported;
+  let dash: any, exported: any;
 
   beforeEach(done => {
     dash = {
@@ -89,25 +92,25 @@ describe('given dashboard with repeated panels', () => {
     config.buildInfo.version = '3.0.2';
 
     //Stubs test function calls
-    const datasourceSrvStub = { get: jest.fn(arg => getStub(arg)) };
+    const datasourceSrvStub = ({ get: jest.fn(arg => getStub(arg)) } as any) as DatasourceSrv;
 
     config.panels['graph'] = {
       id: 'graph',
       name: 'Graph',
       info: { version: '1.1.0' },
-    };
+    } as PanelPlugin;
 
     config.panels['table'] = {
       id: 'table',
       name: 'Table',
       info: { version: '1.1.1' },
-    };
+    } as PanelPlugin;
 
     config.panels['heatmap'] = {
       id: 'heatmap',
       name: 'Heatmap',
       info: { version: '1.1.2' },
-    };
+    } as PanelPlugin;
 
     dash = new DashboardModel(dash, {});
     const exporter = new DashboardExporter(datasourceSrvStub);
@@ -213,7 +216,7 @@ describe('given dashboard with repeated panels', () => {
 });
 
 // Stub responses
-const stubs = [];
+const stubs: { [key: string]: {} } = {};
 stubs['gfdb'] = {
   name: 'gfdb',
   meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' },
@@ -249,6 +252,6 @@ stubs['-- Grafana --'] = {
   },
 };
 
-function getStub(arg) {
+function getStub(arg: string) {
   return Promise.resolve(stubs[arg || 'gfdb']);
 }

+ 47 - 14
public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts

@@ -1,9 +1,42 @@
-import config from 'app/core/config';
+// @ts-ignore
 import _ from 'lodash';
+
+import config from 'app/core/config';
 import { DashboardModel } from '../../state/DashboardModel';
+import DatasourceSrv from 'app/features/plugins/datasource_srv';
+import { PanelModel } from 'app/features/dashboard/state';
+import { PanelPlugin } from 'app/types/plugins';
+
+interface Input {
+  name: string;
+  type: string;
+  label: string;
+  value: any;
+  description: string;
+}
+
+interface Requires {
+  [key: string]: {
+    type: string;
+    id: string;
+    name: string;
+    version: string;
+  };
+}
+
+interface DataSources {
+  [key: string]: {
+    name: string;
+    label: string;
+    description: string;
+    type: string;
+    pluginId: string;
+    pluginName: string;
+  };
+}
 
 export class DashboardExporter {
-  constructor(private datasourceSrv) {}
+  constructor(private datasourceSrv: DatasourceSrv) {}
 
   makeExportable(dashboard: DashboardModel) {
     // clean up repeated rows and panels,
@@ -18,19 +51,19 @@ export class DashboardExporter {
     // undo repeat cleanup
     dashboard.processRepeats();
 
-    const inputs = [];
-    const requires = {};
-    const datasources = {};
-    const promises = [];
-    const variableLookup: any = {};
+    const inputs: Input[] = [];
+    const requires: Requires = {};
+    const datasources: DataSources = {};
+    const promises: Array<Promise<void>> = [];
+    const variableLookup: { [key: string]: any } = {};
 
     for (const variable of saveModel.templating.list) {
       variableLookup[variable.name] = variable;
     }
 
-    const templateizeDatasourceUsage = obj => {
-      let datasource = obj.datasource;
-      let datasourceVariable = null;
+    const templateizeDatasourceUsage = (obj: any) => {
+      let datasource: string = obj.datasource;
+      let datasourceVariable: any = null;
 
       // ignore data source properties that contain a variable
       if (datasource && datasource.indexOf('$') === 0) {
@@ -74,7 +107,7 @@ export class DashboardExporter {
       );
     };
 
-    const processPanel = panel => {
+    const processPanel = (panel: PanelModel) => {
       if (panel.datasource !== undefined) {
         templateizeDatasourceUsage(panel);
       }
@@ -87,7 +120,7 @@ export class DashboardExporter {
         }
       }
 
-      const panelDef = config.panels[panel.type];
+      const panelDef: PanelPlugin = config.panels[panel.type];
       if (panelDef) {
         requires['panel' + panelDef.id] = {
           type: 'panel',
@@ -135,7 +168,7 @@ export class DashboardExporter {
 
     return Promise.all(promises)
       .then(() => {
-        _.each(datasources, (value, key) => {
+        _.each(datasources, (value: any) => {
           inputs.push(value);
         });
 
@@ -160,7 +193,7 @@ export class DashboardExporter {
         }
 
         // make inputs and requires a top thing
-        const newObj = {};
+        const newObj: { [key: string]: {} } = {};
         newObj['__inputs'] = inputs;
         newObj['__requires'] = _.sortBy(requires, ['id']);
 

+ 2 - 7
public/app/features/dashboard/panel_editor/QueryOptions.tsx

@@ -5,17 +5,12 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
 import { isValidTimeSpan } from 'app/core/utils/rangeutil';
 
 // Components
-import { Switch } from '@grafana/ui';
-import { Input } from 'app/core/components/Form';
-import { EventsWithValidation } from 'app/core/components/Form/Input';
-import { InputStatus } from 'app/core/components/Form/Input';
+import { DataSourceSelectItem, EventsWithValidation, Input, InputStatus, Switch, ValidationEvents } from '@grafana/ui';
 import { DataSourceOption } from './DataSourceOption';
 import { FormLabel } from '@grafana/ui';
 
 // Types
-import { PanelModel } from '../state/PanelModel';
-import { DataSourceSelectItem } from '@grafana/ui/src/types';
-import { ValidationEvents } from 'app/types';
+import { PanelModel } from '../state';
 
 const timeRangeValidationEvents: ValidationEvents = {
   [EventsWithValidation.onBlur]: [

+ 13 - 2
public/app/features/dashboard/state/PanelModel.test.ts

@@ -3,9 +3,10 @@ import { PanelModel } from './PanelModel';
 describe('PanelModel', () => {
   describe('when creating new panel model', () => {
     let model;
+    let modelJson;
 
     beforeEach(() => {
-      model = new PanelModel({
+      modelJson = {
         type: 'table',
         showColumns: true,
         targets: [{ refId: 'A' }, { noRefId: true }],
@@ -23,7 +24,8 @@ describe('PanelModel', () => {
             },
           ],
         },
-      });
+      };
+      model = new PanelModel(modelJson);
     });
 
     it('should apply defaults', () => {
@@ -38,6 +40,15 @@ describe('PanelModel', () => {
       expect(model.targets[1].refId).toBe('B');
     });
 
+    it("shouldn't break panel with non-array targets", () => {
+      modelJson.targets = {
+        0: { refId: 'A' },
+        foo: { bar: 'baz' },
+      };
+      model = new PanelModel(modelJson);
+      expect(model.targets[0].refId).toBe('A');
+    });
+
     it('getSaveModel should remove defaults', () => {
       const saveModel = model.getSaveModel();
       expect(saveModel.gridPos).toBe(undefined);

+ 7 - 14
public/app/features/dashboard/state/PanelModel.ts

@@ -1,8 +1,11 @@
 // Libraries
 import _ from 'lodash';
 
-// Types
+// Utils
 import { Emitter } from 'app/core/utils/emitter';
+import { getNextRefIdChar } from 'app/core/utils/query';
+
+// Types
 import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui';
 import { TableData } from '@grafana/ui/src';
 
@@ -125,10 +128,10 @@ export class PanelModel {
   }
 
   ensureQueryIds() {
-    if (this.targets) {
+    if (this.targets && _.isArray(this.targets)) {
       for (const query of this.targets) {
         if (!query.refId) {
-          query.refId = this.getNextQueryLetter();
+          query.refId = getNextRefIdChar(this.targets);
         }
       }
     }
@@ -266,20 +269,10 @@ export class PanelModel {
 
   addQuery(query?: Partial<DataQuery>) {
     query = query || { refId: 'A' };
-    query.refId = this.getNextQueryLetter();
+    query.refId = getNextRefIdChar(this.targets);
     this.targets.push(query as DataQuery);
   }
 
-  getNextQueryLetter(): string {
-    const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
-
-    return _.find(letters, refId => {
-      return _.every(this.targets, other => {
-        return other.refId !== refId;
-      });
-    });
-  }
-
   changeQuery(query: DataQuery, index: number) {
     // ensure refId is maintained
     query.refId = this.targets[index].refId;

+ 3 - 1
public/app/features/explore/LogLabelStats.tsx

@@ -11,7 +11,9 @@ function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) {
   return (
     <div className={className}>
       <div className="logs-stats-row__label">
-        <div className="logs-stats-row__value">{value}</div>
+        <div className="logs-stats-row__value" title={value}>
+          {value}
+        </div>
         <div className="logs-stats-row__count">{count}</div>
         <div className="logs-stats-row__percent">{percent}</div>
       </div>

+ 6 - 2
public/app/features/explore/Table.tsx

@@ -40,11 +40,15 @@ export default class Table extends PureComponent<TableProps> {
     const tableModel = data || EMPTY_TABLE;
     const columnNames = tableModel.columns.map(({ text }) => text);
     const columns = tableModel.columns.map(({ filterable, text }) => ({
-      Header: text,
+      Header: () => <span title={text}>{text}</span>,
       accessor: text,
       className: VALUE_REGEX.test(text) ? 'text-right' : '',
       show: text !== 'Time',
-      Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
+      Cell: row => (
+        <span className={filterable ? 'link' : ''} title={text + ': ' + row.value}>
+          {row.value}
+        </span>
+      ),
     }));
     const noDataText = data ? 'The queries returned no data for a table.' : '';
 

+ 11 - 9
public/app/features/explore/state/actions.ts

@@ -60,7 +60,6 @@ import {
   splitCloseAction,
   splitOpenAction,
   addQueryRowAction,
-  AddQueryRowPayload,
   toggleGraphAction,
   toggleLogsAction,
   toggleTableAction,
@@ -87,9 +86,12 @@ const updateExploreUIState = (exploreId, uiStateFragment: Partial<ExploreUIState
 /**
  * Adds a query row after the row with the given index.
  */
-export function addQueryRow(exploreId: ExploreId, index: number): ActionOf<AddQueryRowPayload> {
-  const query = generateEmptyQuery(index + 1);
-  return addQueryRowAction({ exploreId, index, query });
+export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const query = generateEmptyQuery(getState().explore[exploreId].queries, index);
+
+    dispatch(addQueryRowAction({ exploreId, index, query }));
+  };
 }
 
 /**
@@ -126,10 +128,10 @@ export function changeQuery(
   index: number,
   override: boolean
 ): ThunkResult<void> {
-  return dispatch => {
+  return (dispatch, getState) => {
     // Null query means reset
     if (query === null) {
-      query = { ...generateEmptyQuery(index) };
+      query = { ...generateEmptyQuery(getState().explore[exploreId].queries) };
     }
 
     dispatch(changeQueryAction({ exploreId, query, index, override }));
@@ -287,7 +289,7 @@ export function importQueries(
 
     const nextQueries = importedQueries.map((q, i) => ({
       ...q,
-      ...generateEmptyQuery(i),
+      ...generateEmptyQuery(queries),
     }));
 
     dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
@@ -629,9 +631,9 @@ export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkRes
  * Use this action for clicks on query examples. Triggers a query run.
  */
 export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> {
-  return dispatch => {
+  return (dispatch, getState) => {
     // Inject react keys into query objects
-    const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
+    const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery(getState().explore[exploreId].queries) }));
     dispatch(setQueriesAction({ exploreId, queries }));
     dispatch(runQueries(exploreId));
   };

+ 5 - 3
public/app/features/explore/state/reducers.ts

@@ -127,7 +127,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       const { query, index } = action.payload;
 
       // Override path: queries are completely reset
-      const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(index) };
+      const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(state.queries) };
       const nextQueries = [...queries];
       nextQueries[index] = nextQuery;
 
@@ -267,7 +267,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         // Modify all queries
         nextQueries = queries.map((query, i) => ({
           ...modifier({ ...query }, modification),
-          ...generateEmptyQuery(i),
+          ...generateEmptyQuery(state.queries),
         }));
         // Discard all ongoing transactions
         nextQueryTransactions = [];
@@ -276,7 +276,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         nextQueries = queries.map((query, i) => {
           // Synchronize all queries with local query cache to ensure consistency
           // TODO still needed?
-          return i === index ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(i) } : query;
+          return i === index
+            ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(state.queries) }
+            : query;
         });
         nextQueryTransactions = queryTransactions
           // Consume the hint corresponding to the action

+ 2 - 0
public/app/features/plugins/built_in_plugins.ts

@@ -25,6 +25,7 @@ import * as heatmapPanel from 'app/plugins/panel/heatmap/module';
 import * as tablePanel from 'app/plugins/panel/table/module';
 import * as table2Panel from 'app/plugins/panel/table2/module';
 import * as singlestatPanel from 'app/plugins/panel/singlestat/module';
+import * as singlestatPanel2 from 'app/plugins/panel/singlestat2/module';
 import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
 import * as gaugePanel from 'app/plugins/panel/gauge/module';
 import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
@@ -57,6 +58,7 @@ const builtInPlugins = {
   'app/plugins/panel/table/module': tablePanel,
   'app/plugins/panel/table2/module': table2Panel,
   'app/plugins/panel/singlestat/module': singlestatPanel,
+  'app/plugins/panel/singlestat2/module': singlestatPanel2,
   'app/plugins/panel/gettingstarted/module': gettingStartedPanel,
   'app/plugins/panel/gauge/module': gaugePanel,
   'app/plugins/panel/bargauge/module': barGaugePanel,

+ 22 - 31
public/app/plugins/panel/bargauge/BarGaugePanel.tsx

@@ -2,56 +2,47 @@
 import React, { PureComponent } from 'react';
 
 // Services & Utils
-import { processSingleStatPanelData } from '@grafana/ui';
+import { DisplayValue, PanelProps, BarGauge } from '@grafana/ui';
 import { config } from 'app/core/config';
 
-// Components
-import { BarGauge, VizRepeater } from '@grafana/ui';
-
 // Types
 import { BarGaugeOptions } from './types';
-import { PanelProps, SingleStatValueInfo } from '@grafana/ui/src/types';
-
-interface Props extends PanelProps<BarGaugeOptions> {}
-
-export class BarGaugePanel extends PureComponent<Props> {
-  renderBarGauge(value: SingleStatValueInfo, width, height) {
-    const { replaceVariables, options } = this.props;
-    const { valueOptions } = options;
+import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
+import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
 
-    const prefix = replaceVariables(valueOptions.prefix);
-    const suffix = replaceVariables(valueOptions.suffix);
+export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
+  renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
+    const { options } = this.props;
 
     return (
       <BarGauge
-        value={value.value as number | null}
+        value={value}
         width={width}
         height={height}
-        prefix={prefix}
-        suffix={suffix}
         orientation={options.orientation}
-        unit={valueOptions.unit}
-        decimals={valueOptions.decimals}
         thresholds={options.thresholds}
-        valueMappings={options.valueMappings}
         theme={config.theme}
         displayMode={options.displayMode}
       />
     );
-  }
-
-  render() {
-    const { panelData, options, width, height } = this.props;
+  };
 
-    const values = processSingleStatPanelData({
-      panelData: panelData,
-      stat: options.valueOptions.stat,
-    });
+  getProcessedValues = (): DisplayValue[] => {
+    return getSingleStatValues(this.props);
+  };
 
+  render() {
+    const { height, width, options, panelData } = this.props;
+    const { orientation } = options;
     return (
-      <VizRepeater height={height} width={width} values={values} orientation={options.orientation}>
-        {({ vizHeight, vizWidth, value }) => this.renderBarGauge(value, vizWidth, vizHeight)}
-      </VizRepeater>
+      <ProcessedValuesRepeater
+        getProcessedValues={this.getProcessedValues}
+        renderValue={this.renderValue}
+        width={width}
+        height={height}
+        source={panelData}
+        orientation={orientation}
+      />
     );
   }
 }

+ 2 - 2
public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx

@@ -2,13 +2,13 @@
 import React, { PureComponent } from 'react';
 
 // Components
-import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor';
 import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGroup, FormField } from '@grafana/ui';
 
 // Types
 import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
 import { BarGaugeOptions, orientationOptions, displayModes } from './types';
-import { SingleStatValueOptions } from '../gauge/types';
+import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
+import { SingleStatValueOptions } from '../singlestat2/types';
 
 export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) =>

+ 2 - 10
public/app/plugins/panel/bargauge/module.tsx

@@ -3,18 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui';
 import { BarGaugePanel } from './BarGaugePanel';
 import { BarGaugePanelEditor } from './BarGaugePanelEditor';
 import { BarGaugeOptions, defaults } from './types';
+import { singleStatBaseOptionsCheck } from '../singlestat2/module';
 
 export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel);
 
 reactPanel.setEditor(BarGaugePanelEditor);
 reactPanel.setDefaults(defaults);
-reactPanel.setPanelTypeChangedHook((options: BarGaugeOptions, prevPluginId?: string, prevOptions?: any) => {
-  if (prevOptions && prevOptions.valueOptions) {
-    options.valueOptions = prevOptions.valueOptions;
-    options.thresholds = prevOptions.thresholds;
-    options.maxValue = prevOptions.maxValue;
-    options.minValue = prevOptions.minValue;
-  }
-
-  return options;
-});
+reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);

+ 9 - 13
public/app/plugins/panel/bargauge/types.ts

@@ -1,31 +1,27 @@
-import { Threshold, SelectOptionItem, ValueMapping, VizOrientation } from '@grafana/ui';
-import { SingleStatValueOptions } from '../gauge/types';
+import { VizOrientation, SelectOptionItem } from '@grafana/ui';
+import { SingleStatBaseOptions } from '../singlestat2/types';
 
-export interface BarGaugeOptions {
+export interface BarGaugeOptions extends SingleStatBaseOptions {
   minValue: number;
   maxValue: number;
-  orientation: VizOrientation;
-  valueOptions: SingleStatValueOptions;
-  valueMappings: ValueMapping[];
-  thresholds: Threshold[];
   displayMode: 'basic' | 'lcd' | 'gradient';
 }
 
-export const orientationOptions: SelectOptionItem[] = [
-  { value: VizOrientation.Horizontal, label: 'Horizontal' },
-  { value: VizOrientation.Vertical, label: 'Vertical' },
-];
-
 export const displayModes: SelectOptionItem[] = [
   { value: 'gradient', label: 'Gradient' },
   { value: 'lcd', label: 'Retro LCD' },
   { value: 'basic', label: 'Basic' },
 ];
 
+export const orientationOptions: SelectOptionItem[] = [
+  { value: VizOrientation.Horizontal, label: 'Horizontal' },
+  { value: VizOrientation.Vertical, label: 'Vertical' },
+];
+
 export const defaults: BarGaugeOptions = {
   minValue: 0,
   maxValue: 100,
-  displayMode: 'basic',
+  displayMode: 'lcd',
   orientation: VizOrientation.Horizontal,
   valueOptions: {
     unit: 'none',

+ 23 - 29
public/app/plugins/panel/gauge/GaugePanel.tsx

@@ -2,37 +2,27 @@
 import React, { PureComponent } from 'react';
 
 // Services & Utils
-import { processSingleStatPanelData } from '@grafana/ui';
 import { config } from 'app/core/config';
 
 // Components
-import { Gauge, VizRepeater } from '@grafana/ui';
+import { Gauge } from '@grafana/ui';
 
 // Types
 import { GaugeOptions } from './types';
-import { PanelProps, VizOrientation, SingleStatValueInfo } from '@grafana/ui/src/types';
+import { DisplayValue, PanelProps } from '@grafana/ui';
+import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
+import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
 
-interface Props extends PanelProps<GaugeOptions> {}
-
-export class GaugePanel extends PureComponent<Props> {
-  renderGauge(value: SingleStatValueInfo, width, height) {
-    const { replaceVariables, options } = this.props;
-    const { valueOptions } = options;
-
-    const prefix = replaceVariables(valueOptions.prefix);
-    const suffix = replaceVariables(valueOptions.suffix);
+export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
+  renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
+    const { options } = this.props;
 
     return (
       <Gauge
-        value={value.value as number | null}
+        value={value}
         width={width}
         height={height}
-        prefix={prefix}
-        suffix={suffix}
-        unit={valueOptions.unit}
-        decimals={valueOptions.decimals}
         thresholds={options.thresholds}
-        valueMappings={options.valueMappings}
         showThresholdLabels={options.showThresholdLabels}
         showThresholdMarkers={options.showThresholdMarkers}
         minValue={options.minValue}
@@ -40,20 +30,24 @@ export class GaugePanel extends PureComponent<Props> {
         theme={config.theme}
       />
     );
-  }
-
-  render() {
-    const { panelData, options, height, width } = this.props;
+  };
 
-    const values = processSingleStatPanelData({
-      panelData: panelData,
-      stat: options.valueOptions.stat,
-    });
+  getProcessedValues = (): DisplayValue[] => {
+    return getSingleStatValues(this.props);
+  };
 
+  render() {
+    const { height, width, options, panelData } = this.props;
+    const { orientation } = options;
     return (
-      <VizRepeater height={height} width={width} values={values} orientation={VizOrientation.Auto}>
-        {({ vizHeight, vizWidth, value }) => this.renderGauge(value, vizWidth, vizHeight)}
-      </VizRepeater>
+      <ProcessedValuesRepeater
+        getProcessedValues={this.getProcessedValues}
+        renderValue={this.renderValue}
+        width={width}
+        height={height}
+        source={panelData}
+        orientation={orientation}
+      />
     );
   }
 }

+ 3 - 2
public/app/plugins/panel/gauge/GaugePanelEditor.tsx

@@ -9,9 +9,10 @@ import {
   ValueMapping,
 } from '@grafana/ui';
 
-import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor';
 import { GaugeOptionsBox } from './GaugeOptionsBox';
-import { GaugeOptions, SingleStatValueOptions } from './types';
+import { GaugeOptions } from './types';
+import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
+import { SingleStatValueOptions } from '../singlestat2/types';
 
 export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) =>

+ 2 - 10
public/app/plugins/panel/gauge/module.tsx

@@ -3,18 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui';
 import { GaugePanelEditor } from './GaugePanelEditor';
 import { GaugePanel } from './GaugePanel';
 import { GaugeOptions, defaults } from './types';
+import { singleStatBaseOptionsCheck } from '../singlestat2/module';
 
 export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel);
 
 reactPanel.setEditor(GaugePanelEditor);
 reactPanel.setDefaults(defaults);
-reactPanel.setPanelTypeChangedHook((options: GaugeOptions, prevPluginId?: string, prevOptions?: any) => {
-  if (prevOptions && prevOptions.valueOptions) {
-    options.valueOptions = prevOptions.valueOptions;
-    options.thresholds = prevOptions.thresholds;
-    options.maxValue = prevOptions.maxValue;
-    options.minValue = prevOptions.minValue;
-  }
-
-  return options;
-});
+reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);

+ 4 - 13
public/app/plugins/panel/gauge/types.ts

@@ -1,21 +1,11 @@
-import { Threshold, ValueMapping } from '@grafana/ui';
+import { SingleStatBaseOptions } from '../singlestat2/types';
+import { VizOrientation } from '@grafana/ui';
 
-export interface GaugeOptions {
-  valueMappings: ValueMapping[];
+export interface GaugeOptions extends SingleStatBaseOptions {
   maxValue: number;
   minValue: number;
   showThresholdLabels: boolean;
   showThresholdMarkers: boolean;
-  thresholds: Threshold[];
-  valueOptions: SingleStatValueOptions;
-}
-
-export interface SingleStatValueOptions {
-  unit: string;
-  suffix: string;
-  stat: string;
-  prefix: string;
-  decimals?: number | null;
 }
 
 export const defaults: GaugeOptions = {
@@ -32,4 +22,5 @@ export const defaults: GaugeOptions = {
   },
   valueMappings: [],
   thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
+  orientation: VizOrientation.Auto,
 };

+ 48 - 0
public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx

@@ -0,0 +1,48 @@
+import React, { PureComponent } from 'react';
+import { VizOrientation } from '@grafana/ui';
+import { VizRepeater } from '@grafana/ui';
+
+export interface Props<T> {
+  width: number;
+  height: number;
+  orientation: VizOrientation;
+  source: any; // If this changes, the values will be processed
+  processFlag?: boolean; // change to force processing
+
+  getProcessedValues: () => T[];
+  renderValue: (value: T, width: number, height: number) => JSX.Element;
+}
+
+interface State<T> {
+  values: T[];
+}
+
+/**
+ * This is essentially a cache of processed values.  This checks for changes
+ * to the source and then saves the processed values in the State
+ */
+export class ProcessedValuesRepeater<T> extends PureComponent<Props<T>, State<T>> {
+  constructor(props: Props<T>) {
+    super(props);
+    this.state = {
+      values: props.getProcessedValues(),
+    };
+  }
+
+  componentDidUpdate(prevProps: Props<T>) {
+    const { processFlag, source } = this.props;
+    if (processFlag !== prevProps.processFlag || source !== prevProps.source) {
+      this.setState({ values: this.props.getProcessedValues() });
+    }
+  }
+
+  render() {
+    const { orientation, height, width, renderValue } = this.props;
+    const { values } = this.state;
+    return (
+      <VizRepeater height={height} width={width} values={values} orientation={orientation}>
+        {({ vizHeight, vizWidth, value }) => renderValue(value, vizWidth, vizHeight)}
+      </VizRepeater>
+    );
+  }
+}

+ 9 - 0
public/app/plugins/panel/singlestat2/README.md

@@ -0,0 +1,9 @@
+# Singlestat Panel -  Native Plugin
+
+The Singlestat Panel is **included** with Grafana.
+
+The Singlestat Panel allows you to show the one main summary stat of a SINGLE series. It reduces the series into a single number (by looking at the max, min, average, or sum of values in the series). Singlestat also provides thresholds to color the stat or the Panel background. It can also translate the single number into a text value, and show a sparkline summary of the series.
+
+Read more about it here:
+
+[http://docs.grafana.org/reference/singlestat/](http://docs.grafana.org/reference/singlestat/)

+ 48 - 0
public/app/plugins/panel/singlestat2/SingleStatEditor.tsx

@@ -0,0 +1,48 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import {
+  PanelEditorProps,
+  ThresholdsEditor,
+  Threshold,
+  PanelOptionsGrid,
+  ValueMappingsEditor,
+  ValueMapping,
+} from '@grafana/ui';
+
+import { SingleStatOptions, SingleStatValueOptions } from './types';
+import { SingleStatValueEditor } from './SingleStatValueEditor';
+
+export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
+  onThresholdsChanged = (thresholds: Threshold[]) =>
+    this.props.onOptionsChange({
+      ...this.props.options,
+      thresholds,
+    });
+
+  onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
+    this.props.onOptionsChange({
+      ...this.props.options,
+      valueMappings,
+    });
+
+  onValueOptionsChanged = (valueOptions: SingleStatValueOptions) =>
+    this.props.onOptionsChange({
+      ...this.props.options,
+      valueOptions,
+    });
+
+  render() {
+    const { options } = this.props;
+
+    return (
+      <>
+        <PanelOptionsGrid>
+          <SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} />
+          <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
+        </PanelOptionsGrid>
+
+        <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
+      </>
+    );
+  }
+}

+ 66 - 0
public/app/plugins/panel/singlestat2/SingleStatPanel.tsx

@@ -0,0 +1,66 @@
+// Libraries
+import React, { PureComponent, CSSProperties } from 'react';
+
+// Types
+import { SingleStatOptions, SingleStatBaseOptions } from './types';
+
+import { processSingleStatPanelData, DisplayValue, PanelProps } from '@grafana/ui';
+import { config } from 'app/core/config';
+import { getDisplayProcessor } from '@grafana/ui';
+import { ProcessedValuesRepeater } from './ProcessedValuesRepeater';
+
+export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): DisplayValue[] => {
+  const { panelData, replaceVariables, options } = props;
+  const { valueOptions, valueMappings } = options;
+  const processor = getDisplayProcessor({
+    unit: valueOptions.unit,
+    decimals: valueOptions.decimals,
+    mappings: valueMappings,
+    thresholds: options.thresholds,
+
+    prefix: replaceVariables(valueOptions.prefix),
+    suffix: replaceVariables(valueOptions.suffix),
+    theme: config.theme,
+  });
+  return processSingleStatPanelData({
+    panelData: panelData,
+    stat: valueOptions.stat,
+  }).map(stat => processor(stat.value));
+};
+
+export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
+  renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
+    const style: CSSProperties = {};
+    style.margin = '0 auto';
+    style.fontSize = '250%';
+    style.textAlign = 'center';
+    if (value.color) {
+      style.color = value.color;
+    }
+
+    return (
+      <div style={{ width, height }}>
+        <div style={style}>{value.text}</div>
+      </div>
+    );
+  };
+
+  getProcessedValues = (): DisplayValue[] => {
+    return getSingleStatValues(this.props);
+  };
+
+  render() {
+    const { height, width, options, panelData } = this.props;
+    const { orientation } = options;
+    return (
+      <ProcessedValuesRepeater
+        getProcessedValues={this.getProcessedValues}
+        renderValue={this.renderValue}
+        width={width}
+        height={height}
+        source={panelData}
+        orientation={orientation}
+      />
+    );
+  }
+}

+ 0 - 0
public/app/plugins/panel/gauge/SingleStatValueEditor.tsx → public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx


+ 83 - 0
public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg

@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
+<style type="text/css">
+	.st0{opacity:0.26;fill:url(#SVGID_1_);}
+	.st1{fill:url(#SVGID_2_);}
+	.st2{fill:url(#SVGID_3_);}
+	.st3{fill:url(#SVGID_4_);}
+	.st4{fill:url(#SVGID_5_);}
+	.st5{fill:none;stroke:url(#SVGID_6_);stroke-miterlimit:10;}
+</style>
+<g>
+	<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="50" y1="65.6698" x2="50" y2="93.5681">
+		<stop  offset="0" style="stop-color:#FFF23A"/>
+		<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+		<stop  offset="0.1171" style="stop-color:#FED41A"/>
+		<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+		<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+		<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+		<stop  offset="0.8876" style="stop-color:#ED693C"/>
+		<stop  offset="1" style="stop-color:#E83E39"/>
+	</linearGradient>
+	<path class="st0" d="M97.6,83.8H2.4c-1.3,0-2.4-1.1-2.4-2.4v-1.8l17-1l19.2-4.3l16.3-1.6l16.5,0l15.8-4.7l15.1-3v16.3
+		C100,82.8,98.9,83.8,97.6,83.8z"/>
+	<g>
+		<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.098" y1="76.0776" x2="19.098" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st1" d="M19.6,64.3V38.9l-5.2,3.9l-3.5-6l9.4-6.9h6.8v34.4H19.6z"/>
+		<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="42.412" y1="76.0776" x2="42.412" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st2" d="M53.1,39.4c0,1.1-0.1,2.2-0.4,3.2c-0.3,1-0.7,1.9-1.2,2.8c-0.5,0.9-1,1.7-1.7,2.5c-0.6,0.8-1.2,1.6-1.9,2.3
+			l-6.4,7.4h11.1v6.7H32.3v-6.9l10.5-12c0.8-1,1.5-2,2-3c0.5-1,0.7-2,0.7-2.9c0-1-0.2-1.9-0.7-2.6c-0.5-0.7-1.2-1.1-2.2-1.1
+			c-0.9,0-1.7,0.4-2.3,1.1c-0.6,0.8-1,1.9-1.1,3.3l-7.3-0.7c0.4-3.5,1.6-6.1,3.6-7.9c2-1.7,4.5-2.6,7.4-2.6c1.6,0,3,0.2,4.3,0.7
+			c1.3,0.5,2.3,1.2,3.2,2c0.9,0.9,1.6,1.9,2.1,3.2C52.8,36.4,53.1,37.8,53.1,39.4z"/>
+		<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="60.3739" y1="76.0776" x2="60.3739" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st3" d="M64.5,60.4c0,1.2-0.4,2.3-1.2,3.1c-0.8,0.8-1.8,1.3-3,1.3c-1.2,0-2.2-0.4-3-1.3c-0.8-0.8-1.1-1.9-1.1-3.1
+			c0-1.2,0.4-2.2,1.1-3.1c0.8-0.9,1.8-1.3,3-1.3c1.2,0,2.2,0.4,3,1.3C64.1,58.1,64.5,59.2,64.5,60.4z"/>
+		<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="77.5234" y1="76.0776" x2="77.5234" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st4" d="M85.5,57.4v6.9h-6.9v-6.9H66v-6.6l10.1-20.9h9.4V51H89v6.4H85.5z M78.8,37.5L78.8,37.5l-6,13.5h6V37.5z"/>
+	</g>
+	<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-2.852199e-02" y1="72.3985" x2="100.0976" y2="72.3985">
+		<stop  offset="0" style="stop-color:#F28F3F"/>
+		<stop  offset="1" style="stop-color:#F28F3F"/>
+	</linearGradient>
+	<polyline class="st5" points="0,79.7 17,78.7 36.2,74.4 52.5,72.8 69,72.9 84.9,68.1 100,65.1 	"/>
+</g>
+</svg>

+ 29 - 0
public/app/plugins/panel/singlestat2/module.tsx

@@ -0,0 +1,29 @@
+import { ReactPanelPlugin } from '@grafana/ui';
+import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types';
+import { SingleStatPanel } from './SingleStatPanel';
+import cloneDeep from 'lodash/cloneDeep';
+import { SingleStatEditor } from './SingleStatEditor';
+
+export const reactPanel = new ReactPanelPlugin<SingleStatOptions>(SingleStatPanel);
+
+const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
+
+export const singleStatBaseOptionsCheck = (
+  options: Partial<SingleStatBaseOptions>,
+  prevPluginId?: string,
+  prevOptions?: any
+) => {
+  if (prevOptions) {
+    optionsToKeep.forEach(v => {
+      if (prevOptions.hasOwnProperty(v)) {
+        options[v] = cloneDeep(prevOptions.display);
+      }
+    });
+  }
+
+  return options;
+};
+
+reactPanel.setEditor(SingleStatEditor);
+reactPanel.setDefaults(defaults);
+reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);

+ 20 - 0
public/app/plugins/panel/singlestat2/plugin.json

@@ -0,0 +1,20 @@
+{
+  "type": "panel",
+  "name": "Singlestat (react)",
+  "id": "singlestat2",
+  "state": "alpha",
+
+  "dataFormats": ["time_series", "table"],
+
+  "info": {
+    "description": "Singlestat Panel for Grafana",
+    "author": {
+      "name": "Grafana Project",
+      "url": "https://grafana.com"
+    },
+    "logos": {
+      "small": "img/icn-singlestat-panel.svg",
+      "large": "img/icn-singlestat-panel.svg"
+    }
+  }
+}

部分文件因文件數量過多而無法顯示