Browse Source

Merge branch 'master' into bump-webpack

# Conflicts:
#	package.json
#	yarn.lock
Johannes Schill 6 năm trước cách đây
mục cha
commit
7dec4a9f2d
100 tập tin đã thay đổi với 3712 bổ sung969 xóa
  1. 21 4
      .circleci/config.yml
  2. 15 0
      CHANGELOG.md
  3. 15 1
      conf/defaults.ini
  4. 15 1
      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. 34 1
      docs/sources/installation/configuration.md
  10. 3 0
      docs/sources/permissions/organization_roles.md
  11. 1 1
      docs/sources/reference/templating.md
  12. 2 2
      latest.json
  13. 7 6
      package.json
  14. 3 3
      packages/grafana-ui/package.json
  15. 11 4
      packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx
  16. 5 6
      packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx
  17. 183 116
      packages/grafana-ui/src/components/BarGauge/BarGauge.tsx
  18. 15 331
      packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap
  19. 11 13
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx
  20. 4 88
      packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
  21. 8 56
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  22. 5 7
      packages/grafana-ui/src/components/Input/Input.test.tsx
  23. 14 27
      packages/grafana-ui/src/components/Input/Input.tsx
  24. 0 0
      packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap
  25. 1 0
      packages/grafana-ui/src/components/Select/SelectOption.test.tsx
  26. 1 0
      packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap
  27. 0 2
      packages/grafana-ui/src/components/Table/Table.story.tsx
  28. 13 3
      packages/grafana-ui/src/components/Table/Table.tsx
  29. 16 0
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx
  30. 17 16
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  31. 7 3
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  32. 446 3
      packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap
  33. 1 0
      packages/grafana-ui/src/components/index.ts
  34. 1 2
      packages/grafana-ui/src/themes/_variables.scss.tmpl.ts
  35. 3 17
      packages/grafana-ui/src/types/data.ts
  36. 1 0
      packages/grafana-ui/src/types/index.ts
  37. 0 0
      packages/grafana-ui/src/types/input.ts
  38. 18 11
      packages/grafana-ui/src/types/panel.ts
  39. 1 0
      packages/grafana-ui/src/types/plugin.ts
  40. 0 4
      packages/grafana-ui/src/utils/__snapshots__/processTableData.test.ts.snap
  41. 157 0
      packages/grafana-ui/src/utils/displayValue.test.ts
  42. 145 0
      packages/grafana-ui/src/utils/displayValue.ts
  43. 3 1
      packages/grafana-ui/src/utils/index.ts
  44. 39 1
      packages/grafana-ui/src/utils/processTableData.test.ts
  45. 40 7
      packages/grafana-ui/src/utils/processTableData.ts
  46. 28 9
      packages/grafana-ui/src/utils/processTimeSeries.ts
  47. 0 33
      packages/grafana-ui/src/utils/singlestat.ts
  48. 24 0
      packages/grafana-ui/src/utils/validate.ts
  49. 23 0
      pkg/api/admin_users.go
  50. 138 0
      pkg/api/admin_users_test.go
  51. 21 12
      pkg/api/api.go
  52. 3 3
      pkg/api/app_routes.go
  53. 9 7
      pkg/api/common_test.go
  54. 11 1
      pkg/api/dashboard.go
  55. 2 0
      pkg/api/dashboard_test.go
  56. 1 1
      pkg/api/dataproxy.go
  57. 12 0
      pkg/api/dtos/user_token.go
  58. 8 1
      pkg/api/folder.go
  59. 7 1
      pkg/api/folder_test.go
  60. 1 1
      pkg/api/frontendsettings.go
  61. 21 0
      pkg/api/index.go
  62. 7 1
      pkg/api/pluginproxy/ds_proxy.go
  63. 73 14
      pkg/api/pluginproxy/ds_proxy_test.go
  64. 6 1
      pkg/api/pluginproxy/pluginproxy.go
  65. 56 0
      pkg/api/pluginproxy/pluginproxy_test.go
  66. 69 17
      pkg/api/team.go
  67. 49 5
      pkg/api/team_members.go
  68. 8 2
      pkg/api/team_test.go
  69. 110 0
      pkg/api/user_token.go
  70. 294 0
      pkg/api/user_token_test.go
  71. 1 0
      pkg/cmd/grafana-server/server.go
  72. 126 0
      pkg/infra/remotecache/database_storage.go
  73. 56 0
      pkg/infra/remotecache/database_storage_test.go
  74. 71 0
      pkg/infra/remotecache/memcached_storage.go
  75. 15 0
      pkg/infra/remotecache/memcached_storage_integration_test.go
  76. 62 0
      pkg/infra/remotecache/redis_storage.go
  77. 16 0
      pkg/infra/remotecache/redis_storage_integration_test.go
  78. 133 0
      pkg/infra/remotecache/remotecache.go
  79. 93 0
      pkg/infra/remotecache/remotecache_test.go
  80. 17 0
      pkg/middleware/auth.go
  81. 7 60
      pkg/middleware/middleware_test.go
  82. 2 2
      pkg/middleware/org_redirect_test.go
  83. 3 2
      pkg/middleware/quota_test.go
  84. 2 1
      pkg/middleware/recovery_test.go
  85. 19 14
      pkg/models/team.go
  86. 32 20
      pkg/models/team_member.go
  87. 10 1
      pkg/models/user_token.go
  88. 15 0
      pkg/services/alerting/notifiers/victorops.go
  89. 51 0
      pkg/services/auth/auth_token.go
  90. 41 0
      pkg/services/auth/auth_token_test.go
  91. 81 0
      pkg/services/auth/testing.go
  92. 55 0
      pkg/services/dashboards/acl_service.go
  93. 22 0
      pkg/services/sqlstore/migrations/cache_data_mig.go
  94. 1 0
      pkg/services/sqlstore/migrations/migrations.go
  95. 4 0
      pkg/services/sqlstore/migrations/team_mig.go
  96. 103 19
      pkg/services/sqlstore/team.go
  97. 93 0
      pkg/services/sqlstore/team_test.go
  98. 34 0
      pkg/services/teamguardian/team.go
  99. 87 0
      pkg/services/teamguardian/teams_test.go
  100. 20 3
      pkg/setting/setting.go

+ 21 - 4
.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
@@ -308,7 +322,7 @@ jobs:
 
   deploy-enterprise-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - attach_workspace:
           at: .
@@ -331,7 +345,7 @@ jobs:
 
   deploy-enterprise-release:
     docker:
-    - image: grafana/grafana-ci-deploy:1.2.0
+    - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - checkout
       - attach_workspace:
@@ -364,7 +378,7 @@ jobs:
 
   deploy-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - attach_workspace:
           at: .
@@ -395,7 +409,7 @@ jobs:
 
   deploy-release:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - checkout
       - attach_workspace:
@@ -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

+ 15 - 0
CHANGELOG.md

@@ -7,6 +7,11 @@
 * **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow)
 * **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)
@@ -15,6 +20,16 @@
 * **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619)
 * **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019)
 
+# 6.0.2 (2019-03-19)
+
+### Bug Fixes
+* **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo)
+* **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo)
+* **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr)
+* **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr)
+* **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg)
+* **Datasource admin**: Fixed url of back button in datasource edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop)
+
 # 6.0.1 (2019-03-06)
 
 ### Bug Fixes

+ 15 - 1
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.
@@ -245,7 +259,7 @@ external_manage_info =
 viewers_can_edit = false
 
 # Editors can administrate dashboard, folders and teams they create
-editors_can_own = false
+editors_can_admin = false
 
 [auth]
 # Login cookie name

+ 15 - 1
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.
@@ -225,7 +239,7 @@ log_queries =
 ;viewers_can_edit = false
 
 # Editors can administrate dashboard, folders and teams they create
-;editors_can_own = false
+;editors_can_admin = false
 
 [auth]
 # Login cookie name

+ 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"
+}
+```

+ 34 - 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 />
 
@@ -342,6 +354,11 @@ options are `Admin` and `Editor`. e.g. :
 Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
 Defaults to `false`.
 
+### editors_can_admin
+
+Editors can administrate dashboards, folders and teams they create.
+Defaults to `false`.
+
 ### login_hint
 
 Text used as placeholder text on login page for login/username input.
@@ -399,6 +416,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

+ 3 - 0
docs/sources/permissions/organization_roles.md

@@ -28,6 +28,9 @@ Can do everything scoped to the organization. For example:
 - Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
 - **Cannot** create or edit data sources nor invite new users.
 
+This role can be tweaked via Grafana server setting [editors_can_admin]({{< relref "installation/configuration.md#editors_can_admin" >}}). If you set this to true users
+with **Editor** can also administrate dashboards, folders and teams they create. Useful for enabling self organizing teams.
+
 ## Viewer Role
 
 - View any dashboard. This can be disabled on specific folders and dashboards.

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

@@ -110,7 +110,7 @@ Formats single & multi valued variables for use in URL parameters.
 
 ```bash
 servers = ['foo()bar BAZ', 'test2']
-String to interpolate: '${servers:lucene}'
+String to interpolate: '${servers:percentencode}'
 Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2'
 ```
 

+ 2 - 2
latest.json

@@ -1,4 +1,4 @@
 {
-  "stable": "6.0.1",
-  "testing": "6.0.1"
+  "stable": "6.0.2",
+  "testing": "6.0.2"
 }

+ 7 - 6
package.json

@@ -28,12 +28,13 @@
     "@types/jquery": "^1.10.35",
     "@types/node": "^8.0.31",
     "@types/papaparse": "^4.5.9",
-    "@types/react": "^16.7.6",
-    "@types/react-dom": "^16.0.9",
+    "@types/react": "^16.8.8",
+    "@types/react-dom": "^16.8.2",
     "@types/react-grid-layout": "^0.16.6",
     "@types/react-select": "^2.0.4",
     "@types/react-transition-group": "^2.0.15",
     "@types/react-virtualized": "^9.18.12",
+    "@types/clipboard": "^2.0.1",
     "angular-mocks": "1.6.6",
     "autoprefixer": "^9.4.10",
     "axios": "^0.18.0",
@@ -177,7 +178,7 @@
     "baron": "^3.0.3",
     "brace": "^0.10.0",
     "classnames": "^2.2.6",
-    "clipboard": "^1.7.1",
+    "clipboard": "^2.0.4",
     "d3": "^4.11.0",
     "d3-scale-chromatic": "^1.3.0",
     "eventemitter3": "^2.0.3",
@@ -193,8 +194,8 @@
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.2",
     "rc-cascader": "^0.14.0",
-    "react": "^16.6.3",
-    "react-dom": "^16.6.3",
+    "react": "^16.8.4",
+    "react-dom": "^16.8.4",
     "react-grid-layout": "0.16.6",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
@@ -221,7 +222,7 @@
   },
   "resolutions": {
     "caniuse-db": "1.0.30000772",
-    "**/@types/react": "16.7.6"
+    "**/@types/react": "16.8.8"
   },
   "workspaces": {
     "packages": [

+ 3 - 3
packages/grafana-ui/package.json

@@ -25,10 +25,10 @@
     "lodash": "^4.17.10",
     "moment": "^2.22.2",
     "papaparse": "^4.6.3",
-    "react": "^16.6.3",
+    "react": "^16.8.4",
     "react-color": "^2.17.0",
     "react-custom-scrollbars": "^4.2.1",
-    "react-dom": "^16.6.3",
+    "react-dom": "^16.8.4",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
     "react-transition-group": "^2.2.1",
@@ -48,7 +48,7 @@
     "@types/lodash": "^4.14.119",
     "@types/node": "^10.12.18",
     "@types/papaparse": "^4.5.9",
-    "@types/react": "^16.7.6",
+    "@types/react": "^16.8.8",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-test-renderer": "^16.0.3",
     "@types/react-transition-group": "^2.0.15",

+ 11 - 4
packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx

@@ -1,6 +1,7 @@
 import { storiesOf } from '@storybook/react';
-import { number, text } from '@storybook/addon-knobs';
+import { number, text, boolean } from '@storybook/addon-knobs';
 import { BarGauge } from './BarGauge';
+import { VizOrientation } from '../../types';
 import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
 import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
 
@@ -15,6 +16,8 @@ const getKnobs = () => {
     threshold2Color: text('threshold2Color', 'red'),
     unit: text('unit', 'ms'),
     decimals: number('decimals', 1),
+    horizontal: boolean('horizontal', false),
+    lcd: boolean('lcd', false),
   };
 };
 
@@ -22,7 +25,7 @@ const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module);
 
 BarGaugeStories.addDecorator(withCenteredStory);
 
-BarGaugeStories.add('Vertical, with basic thresholds', () => {
+BarGaugeStories.add('Simple with basic thresholds', () => {
   const {
     value,
     minValue,
@@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
     threshold2Value,
     unit,
     decimals,
+    horizontal,
+    lcd,
   } = getKnobs();
 
   return renderComponentWithTheme(BarGauge, {
-    width: 200,
-    height: 400,
+    width: 700,
+    height: 700,
     value: value,
     minValue: minValue,
     maxValue: maxValue,
@@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
     prefix: '',
     postfix: '',
     decimals: decimals,
+    orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical,
+    displayMode: lcd ? 'lcd' : 'simple',
     thresholds: [
       { index: 0, value: -Infinity, color: 'green' },
       { index: 1, value: threshold1Value, color: threshold1Color },

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

@@ -11,16 +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,
   };

+ 183 - 116
packages/grafana-ui/src/components/BarGauge/BarGauge.tsx

@@ -1,55 +1,54 @@
 // Library
-import React, { PureComponent, CSSProperties } from 'react';
+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';
 }
 
-/*
- * This visualization is still in POC state, needed more tests & better structure
- */
 export class BarGauge extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
     maxValue: 100,
     minValue: 0,
-    value: 100,
-    unit: 'none',
+    value: {
+      text: '100',
+      numeric: 100,
+    },
+    displayMode: 'lcd',
     orientation: VizOrientation.Horizontal,
     thresholds: [],
-    valueMappings: [],
   };
 
-  getNumericValue(): number {
-    if (Number.isFinite(this.props.value as number)) {
-      return this.props.value as number;
+  render() {
+    switch (this.props.displayMode) {
+      case 'lcd':
+        return this.renderRetroBars();
+      case 'basic':
+      case 'gradient':
+      default:
+        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);
@@ -57,41 +56,19 @@ export class BarGauge extends PureComponent<Props> {
       return {
         value: color,
         border: color,
-        bar: tinycolor(color)
-          .setAlpha(0.3)
+        background: tinycolor(color)
+          .setAlpha(0.15)
           .toRgbString(),
       };
     }
 
     return {
       value: getColorFromHexRgbOrName('gray', theme.type),
-      bar: getColorFromHexRgbOrName('gray', theme.type),
+      background: getColorFromHexRgbOrName('gray', theme.type),
       border: getColorFromHexRgbOrName('gray', theme.type),
     };
   }
 
-  getCellColor(positionValue: TimeSeriesValue): string {
-    const { thresholds, theme, value } = this.props;
-    const activeThreshold = getThresholdForValue(thresholds, positionValue);
-
-    if (activeThreshold !== null) {
-      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)) {
-        return tinycolor(color)
-          .setAlpha(0.15)
-          .toRgbString();
-      } else {
-        return tinycolor(color)
-          .setAlpha(0.7)
-          .toRgbString();
-      }
-    }
-
-    return 'gray';
-  }
-
   getValueStyles(value: string, color: string, width: number): CSSProperties {
     const guess = width / (value.length * 1.1);
     const fontSize = Math.min(Math.max(guess, 14), 40);
@@ -102,107 +79,205 @@ export class BarGauge extends PureComponent<Props> {
     };
   }
 
-  renderVerticalBar(valueFormatted: string, valuePercent: number) {
+  /*
+   * Return width or height depending on viz orientation
+   * */
+  get size() {
     const { height, width } = this.props;
+    return this.isVertical ? height : width;
+  }
+
+  get isVertical() {
+    return this.props.orientation === VizOrientation.Vertical;
+  }
+
+  getBarGradient(maxSize: number): string {
+    const { minValue, maxValue, thresholds, value } = this.props;
+    const cssDirection = this.isVertical ? '0deg' : '90deg';
 
-    const maxHeight = height * BAR_SIZE_RATIO;
-    const barHeight = Math.max(valuePercent * maxHeight, 0);
+    let gradient = '';
+    let lastpos = 0;
+
+    for (let i = 0; i < thresholds.length; i++) {
+      const threshold = thresholds[i];
+      const color = getColorFromHexRgbOrName(threshold.color);
+      const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1);
+      const pos = valuePercent * maxSize;
+      const offset = Math.round(pos - (pos - lastpos) / 2);
+
+      if (gradient === '') {
+        gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
+      } else if (value.numeric < threshold.value) {
+        break;
+      } else {
+        lastpos = pos;
+        gradient += ` ${offset}px, ${color}`;
+      }
+    }
+
+    return gradient + ')';
+  }
+
+  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 valueStyles = this.getValueStyles(valueFormatted, colors.value, width);
+    const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
+    const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
+    const isBasic = displayMode === 'basic';
 
     const containerStyles: CSSProperties = {
       width: `${width}px`,
       height: `${height}px`,
       display: 'flex',
-      flexDirection: 'column',
-      justifyContent: 'flex-end',
     };
 
     const barStyles: CSSProperties = {
-      height: `${barHeight}px`,
-      width: `${width}px`,
-      backgroundColor: colors.bar,
-      borderTop: `1px solid ${colors.border}`,
+      borderRadius: '3px',
     };
 
+    if (this.isVertical) {
+      // Custom styles for vertical orientation
+      containerStyles.flexDirection = 'column';
+      containerStyles.justifyContent = 'flex-end';
+      barStyles.transition = 'height 1s';
+      barStyles.height = `${barSize}px`;
+      barStyles.width = `${width}px`;
+      if (isBasic) {
+        // Basic styles
+        barStyles.background = `${colors.background}`;
+        barStyles.border = `1px solid ${colors.border}`;
+        barStyles.boxShadow = `0 0 4px ${colors.border}`;
+      } else {
+        // Gradient styles
+        barStyles.background = this.getBarGradient(maxSize);
+      }
+    } else {
+      // Custom styles for horizontal orientation
+      containerStyles.flexDirection = 'row-reverse';
+      containerStyles.justifyContent = 'flex-end';
+      containerStyles.alignItems = 'center';
+      barStyles.transition = 'width 1s';
+      barStyles.height = `${height}px`;
+      barStyles.width = `${barSize}px`;
+      barStyles.marginRight = '10px';
+
+      if (isBasic) {
+        // Basic styles
+        barStyles.background = `${colors.background}`;
+        barStyles.border = `1px solid ${colors.border}`;
+        barStyles.boxShadow = `0 0 4px ${colors.border}`;
+      } else {
+        // Gradient styles
+        barStyles.background = this.getBarGradient(maxSize);
+      }
+    }
+
     return (
       <div style={containerStyles}>
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
         <div style={barStyles} />
       </div>
     );
   }
 
-  renderHorizontalBar(valueFormatted: string, valuePercent: number) {
-    const { height, width } = this.props;
-
-    const maxWidth = width * BAR_SIZE_RATIO;
-    const barWidth = Math.max(valuePercent * maxWidth, 0);
-    const colors = this.getValueColors();
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
+  getCellColor(positionValue: TimeSeriesValue): CellColors {
+    const { thresholds, theme, value } = this.props;
+    const activeThreshold = getThresholdForValue(thresholds, positionValue);
 
-    valueStyles.marginLeft = '8px';
+    if (activeThreshold !== null) {
+      const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
 
-    const containerStyles: CSSProperties = {
-      width: `${width}px`,
-      height: `${height}px`,
-      display: 'flex',
-      flexDirection: 'row',
-      alignItems: 'center',
-    };
+      // if we are past real value the cell is not "on"
+      if (value === null || (positionValue !== null && positionValue > value.numeric)) {
+        return {
+          background: tinycolor(color)
+            .setAlpha(0.15)
+            .toRgbString(),
+          border: 'transparent',
+          isLit: false,
+        };
+      } else {
+        return {
+          background: tinycolor(color)
+            .setAlpha(0.85)
+            .toRgbString(),
+          backgroundShade: tinycolor(color)
+            .setAlpha(0.55)
+            .toRgbString(),
+          border: tinycolor(color)
+            .setAlpha(0.9)
+            .toRgbString(),
+          isLit: true,
+        };
+      }
+    }
 
-    const barStyles = {
-      height: `${height}px`,
-      width: `${barWidth}px`,
-      backgroundColor: colors.bar,
-      borderRight: `1px solid ${colors.border}`,
+    return {
+      background: 'gray',
+      border: 'gray',
     };
-
-    return (
-      <div style={containerStyles}>
-        <div style={barStyles} />
-        <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
-        </div>
-      </div>
-    );
   }
 
-  renderHorizontalLCD(valueFormatted: string, valuePercent: number) {
-    const { height, width, maxValue, minValue } = this.props;
+  renderRetroBars(): ReactNode {
+    const { height, width, maxValue, minValue, value } = this.props;
 
     const valueRange = maxValue - minValue;
-    const maxWidth = width * BAR_SIZE_RATIO;
-    const cellSpacing = 4;
-    const cellCount = 30;
-    const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount;
+    const maxSize = this.size * BAR_SIZE_RATIO;
+    const cellSpacing = 5;
+    const cellCount = maxSize / 20;
+    const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
     const colors = this.getValueColors();
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
-    valueStyles.marginLeft = '8px';
+    const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
+    const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
 
     const containerStyles: CSSProperties = {
       width: `${width}px`,
       height: `${height}px`,
       display: 'flex',
-      flexDirection: 'row',
-      alignItems: 'center',
     };
 
+    if (this.isVertical) {
+      containerStyles.flexDirection = 'column-reverse';
+      containerStyles.alignItems = 'center';
+      valueStyles.marginBottom = '20px';
+    } else {
+      containerStyles.flexDirection = 'row';
+      containerStyles.alignItems = 'center';
+      valueStyles.marginLeft = '20px';
+    }
+
     const cells: JSX.Element[] = [];
 
     for (let i = 0; i < cellCount; i++) {
       const currentValue = (valueRange / cellCount) * i;
       const cellColor = this.getCellColor(currentValue);
       const cellStyles: CSSProperties = {
-        width: `${cellWidth}px`,
-        backgroundColor: cellColor,
-        marginRight: '4px',
-        height: `${height}px`,
         borderRadius: '2px',
       };
 
+      if (cellColor.isLit) {
+        cellStyles.boxShadow = `0 0 4px ${cellColor.border}`;
+        cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`;
+      } else {
+        cellStyles.backgroundColor = cellColor.background;
+      }
+
+      if (this.isVertical) {
+        cellStyles.height = `${cellSize}px`;
+        cellStyles.width = `${width}px`;
+        cellStyles.marginTop = `${cellSpacing}px`;
+      } else {
+        cellStyles.width = `${cellSize}px`;
+        cellStyles.height = `${height}px`;
+        cellStyles.marginRight = `${cellSpacing}px`;
+      }
+
       cells.push(<div style={cellStyles} />);
     }
 
@@ -210,30 +285,22 @@ export class BarGauge extends PureComponent<Props> {
       <div style={containerStyles}>
         {cells}
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
       </div>
     );
   }
-
-  render() {
-    const { maxValue, minValue, orientation, unit, decimals } = this.props;
-
-    const numericValue = this.getNumericValue();
-    const valuePercent = Math.min(numericValue / (maxValue - minValue), 1);
-
-    const formatFunc = getValueFormat(unit);
-    const valueFormatted = formatFunc(numericValue, decimals);
-    const vertical = orientation === 'vertical';
-
-    return vertical
-      ? this.renderVerticalBar(valueFormatted, valuePercent)
-      : this.renderHorizontalLCD(valueFormatted, valuePercent);
-  }
 }
 
 interface BarColors {
   value: string;
-  bar: string;
+  background: string;
+  border: string;
+}
+
+interface CellColors {
+  background: string;
+  backgroundShade?: string;
   border: string;
+  isLit?: boolean;
 }

+ 15 - 331
packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap

@@ -6,353 +6,37 @@ exports[`Render BarGauge with basic options should render 1`] = `
     Object {
       "alignItems": "center",
       "display": "flex",
-      "flexDirection": "row",
+      "flexDirection": "row-reverse",
       "height": "300px",
+      "justifyContent": "flex-end",
       "width": "300px",
     }
   }
 >
   <div
+    className="bar-gauge__value"
     style={
       Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
+        "color": "#7EB26D",
+        "fontSize": "27.27272727272727px",
       }
     }
-  />
+  >
+    25
+  </div>
   <div
     style={
       Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
+        "background": "rgba(126, 178, 109, 0.15)",
+        "border": "1px solid #7EB26D",
+        "borderRadius": "3px",
+        "boxShadow": "0 0 4px #7EB26D",
         "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
+        "marginRight": "10px",
+        "transition": "width 1s",
+        "width": "60px",
       }
     }
   />
-  <div
-    className="bar-gauge__value"
-    style={
-      Object {
-        "color": "#7EB26D",
-        "fontSize": "27.272727272727263px",
-        "marginLeft": "8px",
-      }
-    }
-  >
-    25
-  </div>
 </div>
 `;

+ 11 - 13
packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx

@@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
 
 interface Props {
   onConfirm(): void;
+  disabled?: boolean;
 }
 
 interface State {
@@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
   };
 
   render() {
-    const { onConfirm } = this.props;
-    let showConfirm;
-    let showDeleteButton;
-
-    if (this.state.showConfirm) {
-      showConfirm = 'show';
-      showDeleteButton = 'hide';
-    } else {
-      showConfirm = 'hide';
-      showDeleteButton = 'show';
-    }
+    const { onConfirm, disabled } = this.props;
+    const showConfirmClass = this.state.showConfirm ? 'show' : 'hide';
+    const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show';
+    const disabledClass = disabled ? 'disabled btn-inverse' : '';
+    const onClick = disabled ? () => {} : this.onClickDelete;
 
     return (
       <span className="delete-button-container">
-        <a className={'delete-button ' + showDeleteButton + ' btn btn-danger btn-small'} onClick={this.onClickDelete}>
+        <a
+          className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`}
+          onClick={onClick}
+        >
           <i className="fa fa-remove" />
         </a>
         <span className="confirm-delete-container">
-          <span className={'confirm-delete ' + showConfirm}>
+          <span className={`confirm-delete ${showConfirmClass}`}>
             <a className="btn btn-small" onClick={this.onClickCancel}>
               Cancel
             </a>

+ 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 - 0
packages/grafana-ui/src/components/Select/SelectOption.test.tsx

@@ -25,6 +25,7 @@ const model: OptionProps<any> = {
     key: '',
     onClick: jest.fn(),
     onMouseOver: jest.fn(),
+    onMouseMove: jest.fn(),
     tabIndex: 1,
   },
   label: 'Option label',

+ 1 - 0
packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap

@@ -4,6 +4,7 @@ exports[`SelectOption renders correctly 1`] = `
 <div
   id=""
   onClick={[MockFunction]}
+  onMouseMove={[MockFunction]}
   onMouseOver={[MockFunction]}
   tabIndex={1}
 >

+ 0 - 2
packages/grafana-ui/src/components/Table/Table.story.tsx

@@ -40,8 +40,6 @@ export function makeDummyTable(columnCount: number, rowCount: number): TableData
       const suffix = (rowId + 1).toString();
       return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
     }),
-    type: 'table',
-    columnMap: {},
   };
 }
 

+ 13 - 3
packages/grafana-ui/src/components/Table/Table.tsx

@@ -8,6 +8,7 @@ import {
   CellMeasurerCache,
   CellMeasurer,
   GridCellProps,
+  Index,
 } from 'react-virtualized';
 import { Themeable } from '../../types/theme';
 
@@ -26,6 +27,7 @@ import { stringToJsRegex } from '../../utils/index';
 export interface Props extends Themeable {
   data: TableData;
 
+  minColumnWidth: number;
   showHeader: boolean;
   fixedHeader: boolean;
   fixedColumns: number;
@@ -46,6 +48,7 @@ interface State {
 
 interface ColumnRenderInfo {
   header: string;
+  width: number;
   builder: TableCellBuilder;
 }
 
@@ -64,6 +67,7 @@ export class Table extends Component<Props, State> {
     fixedHeader: true,
     fixedColumns: 0,
     rotate: false,
+    minColumnWidth: 150,
   };
 
   constructor(props: Props) {
@@ -76,7 +80,7 @@ export class Table extends Component<Props, State> {
     this.renderer = this.initColumns(props);
     this.measurer = new CellMeasurerCache({
       defaultHeight: 30,
-      defaultWidth: 150,
+      fixedWidth: true,
     });
   }
 
@@ -110,7 +114,8 @@ export class Table extends Component<Props, State> {
 
   /** Given the configuration, setup how each column gets rendered */
   initColumns(props: Props): ColumnRenderInfo[] {
-    const { styles, data } = props;
+    const { styles, data, width, minColumnWidth } = props;
+    const columnWidth = Math.max(width / data.columns.length, minColumnWidth);
 
     return data.columns.map((col, index) => {
       let title = col.text;
@@ -131,6 +136,7 @@ export class Table extends Component<Props, State> {
 
       return {
         header: title,
+        width: columnWidth,
         builder: getCellBuilder(col, style, this.props),
       };
     });
@@ -228,6 +234,10 @@ export class Table extends Component<Props, State> {
     );
   };
 
+  getColumnWidth = (col: Index): number => {
+    return this.renderer[col.index].width;
+  };
+
   render() {
     const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props;
     const { data } = this.state;
@@ -269,7 +279,7 @@ export class Table extends Component<Props, State> {
         rowCount={rowCount}
         overscanColumnCount={8}
         overscanRowCount={8}
-        columnWidth={this.measurer.columnWidth}
+        columnWidth={this.getColumnWidth}
         deferredMeasurementCache={this.measurer}
         cellRenderer={this.cellRenderer}
         rowHeight={this.measurer.rowHeight}

+ 16 - 0
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+
+import { ThresholdsEditor } from './ThresholdsEditor';
+
+const ThresholdsEditorStories = storiesOf('UI/ThresholdsEditor', module);
+const thresholds = [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 50, color: 'red' }];
+
+ThresholdsEditorStories.add('default', () => {
+  return <ThresholdsEditor thresholds={[]} onChange={action('Thresholds changed')} />;
+});
+
+ThresholdsEditorStories.add('with thresholds', () => {
+  return <ThresholdsEditor thresholds={thresholds} onChange={action('Thresholds changed')} />;
+});

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

@@ -1,6 +1,7 @@
 import React, { ChangeEvent } from 'react';
 import { mount } from 'enzyme';
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
+import { colors } from '../../utils';
 
 const setup = (propOverrides?: Partial<Props>) => {
   const props: Props = {
@@ -31,7 +32,7 @@ describe('Initialization', () => {
   it('should add a base threshold if missing', () => {
     const { instance } = setup();
 
-    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
   });
 });
 
@@ -41,7 +42,7 @@ describe('Add threshold', () => {
 
     instance.onAddThreshold(0);
 
-    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
   });
 
   it('should add threshold', () => {
@@ -50,41 +51,41 @@ describe('Add threshold', () => {
     instance.onAddThreshold(1);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: -Infinity, color: '#7EB26D' },
-      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: colors[0] },
+      { index: 1, value: 50, color: colors[2] },
     ]);
   });
 
   it('should add another threshold above a first', () => {
     const { instance } = setup({
-      thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
+      thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }],
     });
 
     instance.onAddThreshold(2);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: -Infinity, color: '#7EB26D' },
-      { index: 1, value: 50, color: '#EAB839' },
-      { index: 2, value: 75, color: '#6ED0E0' },
+      { index: 0, value: -Infinity, color: colors[0] },
+      { index: 1, value: 50, color: colors[2] },
+      { index: 2, value: 75, color: colors[3] },
     ]);
   });
 
   it('should add another threshold between first and second index', () => {
     const { instance } = setup({
       thresholds: [
-        { index: 0, value: -Infinity, color: '#7EB26D' },
-        { index: 1, value: 50, color: '#EAB839' },
-        { index: 2, value: 75, color: '#6ED0E0' },
+        { index: 0, value: -Infinity, color: colors[0] },
+        { index: 1, value: 50, color: colors[2] },
+        { index: 2, value: 75, color: colors[3] },
       ],
     });
 
     instance.onAddThreshold(2);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: -Infinity, color: '#7EB26D' },
-      { index: 1, value: 50, color: '#EAB839' },
-      { index: 2, value: 62.5, color: '#EF843C' },
-      { index: 3, value: 75, color: '#6ED0E0' },
+      { index: 0, value: -Infinity, color: colors[0] },
+      { index: 1, value: 50, color: colors[2] },
+      { index: 2, value: 62.5, color: colors[4] },
+      { index: 3, value: 75, color: colors[3] },
     ]);
   });
 });
@@ -161,7 +162,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' },

+ 7 - 3
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx

@@ -3,8 +3,8 @@ import { Threshold } from '../../types';
 import { ColorPicker } from '..';
 import { PanelOptionsGroup } from '..';
 import { colors } from '../../utils';
-import { ThemeContext } from '../../themes/ThemeContext';
-import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
+import { ThemeContext } from '../../themes';
+import { getColorFromHexRgbOrName } from '../../utils';
 
 export interface Props {
   thresholds: Threshold[];
@@ -166,7 +166,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
         <div className="thresholds-row-input-inner-color">
           {threshold.color && (
             <div className="thresholds-row-input-inner-color-colorpicker">
-              <ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
+              <ColorPicker
+                color={threshold.color}
+                onChange={color => this.onChangeThresholdColor(threshold, color)}
+                enableNamedColors={true}
+              />
             </div>
           )}
         </div>

+ 446 - 3
packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap

@@ -1,7 +1,450 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`Render should render with base threshold 1`] = `
-<ContextConsumer>
-  <Component />
-</ContextConsumer>
+<ThresholdsEditor
+  onChange={
+    [MockFunction] {
+      "calls": Array [
+        Array [
+          Array [
+            Object {
+              "color": "#7EB26D",
+              "index": 0,
+              "value": -Infinity,
+            },
+          ],
+        ],
+      ],
+      "results": Array [
+        Object {
+          "isThrow": false,
+          "value": undefined,
+        },
+      ],
+    }
+  }
+  thresholds={Array []}
+>
+  <Component
+    title="Thresholds"
+  >
+    <div
+      className="panel-options-group"
+    >
+      <div
+        className="panel-options-group__header"
+      >
+        <span
+          className="panel-options-group__title"
+        >
+          Thresholds
+        </span>
+      </div>
+      <div
+        className="panel-options-group__body"
+      >
+        <div
+          className="thresholds"
+        >
+          <div
+            className="thresholds-row"
+            key="0-0"
+          >
+            <div
+              className="thresholds-row-add-button"
+              onClick={[Function]}
+            >
+              <i
+                className="fa fa-plus"
+              />
+            </div>
+            <div
+              className="thresholds-row-color-indicator"
+              style={
+                Object {
+                  "backgroundColor": "#7EB26D",
+                }
+              }
+            />
+            <div
+              className="thresholds-row-input"
+            >
+              <div
+                className="thresholds-row-input-inner"
+              >
+                <span
+                  className="thresholds-row-input-inner-arrow"
+                />
+                <div
+                  className="thresholds-row-input-inner-color"
+                >
+                  <div
+                    className="thresholds-row-input-inner-color-colorpicker"
+                  >
+                    <WithTheme(ColorPicker)
+                      color="#7EB26D"
+                      enableNamedColors={true}
+                      onChange={[Function]}
+                    >
+                      <ColorPicker
+                        color="#7EB26D"
+                        enableNamedColors={true}
+                        onChange={[Function]}
+                        theme={
+                          Object {
+                            "background": Object {
+                              "dropdown": "#1f1f20",
+                              "scrollbar": "#343436",
+                              "scrollbar2": "#343436",
+                            },
+                            "border": Object {
+                              "radius": Object {
+                                "lg": "5px",
+                                "md": "3px",
+                                "sm": "2px",
+                              },
+                              "width": Object {
+                                "sm": "1px",
+                              },
+                            },
+                            "breakpoints": Object {
+                              "lg": "992px",
+                              "md": "768px",
+                              "sm": "544px",
+                              "xl": "1200px",
+                              "xs": "0",
+                            },
+                            "colors": Object {
+                              "black": "#000000",
+                              "blue": "#33b5e5",
+                              "blueBase": "#3274d9",
+                              "blueFaint": "#041126",
+                              "blueLight": "#5794f2",
+                              "blueShade": "#1f60c4",
+                              "body": "#d8d9da",
+                              "bodyBg": "#161719",
+                              "brandDanger": "#e02f44",
+                              "brandPrimary": "#eb7b18",
+                              "brandSuccess": "#299c46",
+                              "brandWarning": "#eb7b18",
+                              "critical": "#e02f44",
+                              "dark1": "#141414",
+                              "dark10": "#424345",
+                              "dark2": "#161719",
+                              "dark3": "#1f1f20",
+                              "dark4": "#212124",
+                              "dark5": "#222426",
+                              "dark6": "#262628",
+                              "dark7": "#292a2d",
+                              "dark8": "#2f2f32",
+                              "dark9": "#343436",
+                              "gray1": "#555555",
+                              "gray2": "#8e8e8e",
+                              "gray3": "#b3b3b3",
+                              "gray4": "#d8d9da",
+                              "gray5": "#ececec",
+                              "gray6": "#f4f5f8",
+                              "gray7": "#fbfbfb",
+                              "grayBlue": "#212327",
+                              "greenBase": "#299c46",
+                              "greenShade": "#23843b",
+                              "headingColor": "#e3e3e3",
+                              "inputBlack": "#09090b",
+                              "link": "#e3e3e3",
+                              "linkDisabled": "#e3e3e3",
+                              "linkExternal": "#33b5e5",
+                              "linkHover": "#ffffff",
+                              "online": "#299c46",
+                              "orange": "#eb7b18",
+                              "pageBg": "#161719",
+                              "purple": "#9933cc",
+                              "queryGreen": "#74e680",
+                              "queryKeyword": "#66d9ef",
+                              "queryOrange": "#eb7b18",
+                              "queryPurple": "#fe85fc",
+                              "queryRed": "#e02f44",
+                              "red": "#d44a3a",
+                              "redBase": "#e02f44",
+                              "redShade": "#c4162a",
+                              "text": "#d8d9da",
+                              "textEmphasis": "#ececec",
+                              "textFaint": "#222426",
+                              "textStrong": "#ffffff",
+                              "textWeak": "#8e8e8e",
+                              "variable": "#32d1df",
+                              "warn": "#f79520",
+                              "white": "#ffffff",
+                              "yellow": "#ecbb13",
+                            },
+                            "name": "Grafana Dark",
+                            "panelPadding": Object {
+                              "horizontal": 10,
+                              "vertical": 5,
+                            },
+                            "spacing": Object {
+                              "d": "14px",
+                              "gutter": "30px",
+                              "lg": "24px",
+                              "md": "16px",
+                              "sm": "8px",
+                              "xl": "32px",
+                              "xs": "4px",
+                              "xxs": "2px",
+                            },
+                            "type": "dark",
+                            "typography": Object {
+                              "fontFamily": Object {
+                                "monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
+                                "sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
+                              },
+                              "heading": Object {
+                                "h1": "28px",
+                                "h2": "24px",
+                                "h3": "21px",
+                                "h4": "18px",
+                                "h5": "16px",
+                                "h6": "14px",
+                              },
+                              "lineHeight": Object {
+                                "lg": 1.5,
+                                "md": 1.3333333333333333,
+                                "sm": 1.1,
+                                "xs": 1,
+                              },
+                              "size": Object {
+                                "base": "13px",
+                                "lg": "18px",
+                                "md": "14px",
+                                "root": "14px",
+                                "sm": "12px",
+                                "xs": "10px",
+                              },
+                              "weight": Object {
+                                "light": 300,
+                                "regular": 400,
+                                "semibold": 500,
+                              },
+                            },
+                          }
+                        }
+                      >
+                        <PopperController
+                          content={
+                            <ColorPickerPopover
+                              color="#7EB26D"
+                              enableNamedColors={true}
+                              onChange={[Function]}
+                              theme={
+                                Object {
+                                  "background": Object {
+                                    "dropdown": "#1f1f20",
+                                    "scrollbar": "#343436",
+                                    "scrollbar2": "#343436",
+                                  },
+                                  "border": Object {
+                                    "radius": Object {
+                                      "lg": "5px",
+                                      "md": "3px",
+                                      "sm": "2px",
+                                    },
+                                    "width": Object {
+                                      "sm": "1px",
+                                    },
+                                  },
+                                  "breakpoints": Object {
+                                    "lg": "992px",
+                                    "md": "768px",
+                                    "sm": "544px",
+                                    "xl": "1200px",
+                                    "xs": "0",
+                                  },
+                                  "colors": Object {
+                                    "black": "#000000",
+                                    "blue": "#33b5e5",
+                                    "blueBase": "#3274d9",
+                                    "blueFaint": "#041126",
+                                    "blueLight": "#5794f2",
+                                    "blueShade": "#1f60c4",
+                                    "body": "#d8d9da",
+                                    "bodyBg": "#161719",
+                                    "brandDanger": "#e02f44",
+                                    "brandPrimary": "#eb7b18",
+                                    "brandSuccess": "#299c46",
+                                    "brandWarning": "#eb7b18",
+                                    "critical": "#e02f44",
+                                    "dark1": "#141414",
+                                    "dark10": "#424345",
+                                    "dark2": "#161719",
+                                    "dark3": "#1f1f20",
+                                    "dark4": "#212124",
+                                    "dark5": "#222426",
+                                    "dark6": "#262628",
+                                    "dark7": "#292a2d",
+                                    "dark8": "#2f2f32",
+                                    "dark9": "#343436",
+                                    "gray1": "#555555",
+                                    "gray2": "#8e8e8e",
+                                    "gray3": "#b3b3b3",
+                                    "gray4": "#d8d9da",
+                                    "gray5": "#ececec",
+                                    "gray6": "#f4f5f8",
+                                    "gray7": "#fbfbfb",
+                                    "grayBlue": "#212327",
+                                    "greenBase": "#299c46",
+                                    "greenShade": "#23843b",
+                                    "headingColor": "#e3e3e3",
+                                    "inputBlack": "#09090b",
+                                    "link": "#e3e3e3",
+                                    "linkDisabled": "#e3e3e3",
+                                    "linkExternal": "#33b5e5",
+                                    "linkHover": "#ffffff",
+                                    "online": "#299c46",
+                                    "orange": "#eb7b18",
+                                    "pageBg": "#161719",
+                                    "purple": "#9933cc",
+                                    "queryGreen": "#74e680",
+                                    "queryKeyword": "#66d9ef",
+                                    "queryOrange": "#eb7b18",
+                                    "queryPurple": "#fe85fc",
+                                    "queryRed": "#e02f44",
+                                    "red": "#d44a3a",
+                                    "redBase": "#e02f44",
+                                    "redShade": "#c4162a",
+                                    "text": "#d8d9da",
+                                    "textEmphasis": "#ececec",
+                                    "textFaint": "#222426",
+                                    "textStrong": "#ffffff",
+                                    "textWeak": "#8e8e8e",
+                                    "variable": "#32d1df",
+                                    "warn": "#f79520",
+                                    "white": "#ffffff",
+                                    "yellow": "#ecbb13",
+                                  },
+                                  "name": "Grafana Dark",
+                                  "panelPadding": Object {
+                                    "horizontal": 10,
+                                    "vertical": 5,
+                                  },
+                                  "spacing": Object {
+                                    "d": "14px",
+                                    "gutter": "30px",
+                                    "lg": "24px",
+                                    "md": "16px",
+                                    "sm": "8px",
+                                    "xl": "32px",
+                                    "xs": "4px",
+                                    "xxs": "2px",
+                                  },
+                                  "type": "dark",
+                                  "typography": Object {
+                                    "fontFamily": Object {
+                                      "monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
+                                      "sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
+                                    },
+                                    "heading": Object {
+                                      "h1": "28px",
+                                      "h2": "24px",
+                                      "h3": "21px",
+                                      "h4": "18px",
+                                      "h5": "16px",
+                                      "h6": "14px",
+                                    },
+                                    "lineHeight": Object {
+                                      "lg": 1.5,
+                                      "md": 1.3333333333333333,
+                                      "sm": 1.1,
+                                      "xs": 1,
+                                    },
+                                    "size": Object {
+                                      "base": "13px",
+                                      "lg": "18px",
+                                      "md": "14px",
+                                      "root": "14px",
+                                      "sm": "12px",
+                                      "xs": "10px",
+                                    },
+                                    "weight": Object {
+                                      "light": 300,
+                                      "regular": 400,
+                                      "semibold": 500,
+                                    },
+                                  },
+                                }
+                              }
+                            />
+                          }
+                          hideAfter={300}
+                        >
+                          <ForwardRef(ColorPickerTrigger)
+                            color="#7EB26D"
+                            onClick={[Function]}
+                            onMouseLeave={[Function]}
+                          >
+                            <div
+                              onClick={[Function]}
+                              onMouseLeave={[Function]}
+                              style={
+                                Object {
+                                  "background": "inherit",
+                                  "border": "none",
+                                  "borderRadius": 10,
+                                  "color": "inherit",
+                                  "cursor": "pointer",
+                                  "overflow": "hidden",
+                                  "padding": 0,
+                                }
+                              }
+                            >
+                              <div
+                                style={
+                                  Object {
+                                    "backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)",
+                                    "border": "none",
+                                    "float": "left",
+                                    "height": 15,
+                                    "margin": 0,
+                                    "position": "relative",
+                                    "width": 15,
+                                    "zIndex": 0,
+                                  }
+                                }
+                              >
+                                <div
+                                  style={
+                                    Object {
+                                      "backgroundColor": "#7EB26D",
+                                      "bottom": 0,
+                                      "display": "block",
+                                      "left": 0,
+                                      "position": "absolute",
+                                      "right": 0,
+                                      "top": 0,
+                                    }
+                                  }
+                                />
+                              </div>
+                            </div>
+                          </ForwardRef(ColorPickerTrigger)>
+                        </PopperController>
+                      </ColorPicker>
+                    </WithTheme(ColorPicker)>
+                  </div>
+                </div>
+                <div
+                  className="thresholds-row-input-inner-value"
+                >
+                  <input
+                    readOnly={true}
+                    type="text"
+                    value="Base"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </Component>
+</ThresholdsEditor>
 `;

+ 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 - 2
packages/grafana-ui/src/themes/_variables.scss.tmpl.ts

@@ -120,7 +120,7 @@ $headings-line-height: ${theme.typography.lineHeight.sm} !default;
 $border-width: ${theme.border.width.sm} !default;
 
 $border-radius: ${theme.border.radius.md} !default;
-$border-radius-lg: ${theme.border.radius.lg}!default;
+$border-radius-lg: ${theme.border.radius.lg} !default;
 $border-radius-sm: ${theme.border.radius.sm} !default;
 
 // Page
@@ -191,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
 $btn-padding-x-xl: 21px !default;
 $btn-padding-y-xl: 11px !default;
 
-
 $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
 
 // sidemenu

+ 3 - 17
packages/grafana-ui/src/types/data.ts

@@ -51,27 +51,13 @@ export enum NullValueMode {
 export type TimeSeriesVMs = TimeSeriesVM[];
 
 export interface Column {
-  text: string;
-  title?: string;
-  type?: string;
-  sort?: boolean;
-  desc?: boolean;
-  filterable?: boolean;
+  text: string; // The column name
+  type?: 'time' | 'number' | 'string' | 'object'; // not used anywhere? can we remove?
+  filterable?: boolean; // currently only set by elasticsearch, and used in the table panel
   unit?: string;
 }
 
 export interface TableData {
   columns: Column[];
   rows: any[];
-  type: string;
-  columnMap: any;
-}
-
-export type SingleStatValue = number | string | null;
-
-/*
- * So we can add meta info like tags & series name
- */
-export interface SingleStatValueInfo {
-  value: SingleStatValue;
 }

+ 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


+ 18 - 11
packages/grafana-ui/src/types/panel.ts

@@ -1,12 +1,12 @@
 import { ComponentClass } from 'react';
-import { TimeSeries, LoadingState, TableData } from './data';
+import { LoadingState, TableData } from './data';
 import { TimeRange } from './time';
 import { ScopedVars } from './datasource';
 
 export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
 
 export interface PanelProps<T = any> {
-  panelData: PanelData;
+  data?: TableData[];
   timeRange: TimeRange;
   loading: LoadingState;
   options: T;
@@ -16,23 +16,26 @@ export interface PanelProps<T = any> {
   replaceVariables: InterpolateFunction;
 }
 
-export interface PanelData {
-  timeSeries?: TimeSeries[];
-  tableData?: TableData;
-}
-
 export interface PanelEditorProps<T = any> {
   options: T;
   onOptionsChange: (options: T) => void;
 }
 
-export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
+/**
+ * Called before a panel is initalized
+ */
+export type PanelTypeChangedHook<TOptions = any> = (
+  options: Partial<TOptions>,
+  prevPluginId?: string,
+  prevOptions?: any
+) => Partial<TOptions>;
 
 export class ReactPanelPlugin<TOptions = any> {
   panel: ComponentClass<PanelProps<TOptions>>;
   editor?: ComponentClass<PanelEditorProps<TOptions>>;
   defaults?: TOptions;
-  preserveOptions?: PreservePanelOptionsHandler<TOptions>;
+
+  panelTypeChangedHook?: PanelTypeChangedHook<TOptions>;
 
   constructor(panel: ComponentClass<PanelProps<TOptions>>) {
     this.panel = panel;
@@ -46,8 +49,12 @@ export class ReactPanelPlugin<TOptions = any> {
     this.defaults = defaults;
   }
 
-  setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
-    this.preserveOptions = handler;
+  /**
+   * Called when the visualization changes.
+   * Lets you keep whatever settings made sense in the previous panel
+   */
+  setPanelTypeChangedHook(v: PanelTypeChangedHook<TOptions>) {
+    this.panelTypeChangedHook = v;
   }
 }
 

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

+ 0 - 4
packages/grafana-ui/src/utils/__snapshots__/processTableData.test.ts.snap

@@ -2,7 +2,6 @@
 
 exports[`processTableData basic processing should generate a header and fix widths 1`] = `
 Object {
-  "columnMap": Object {},
   "columns": Array [
     Object {
       "text": "Column 1",
@@ -31,13 +30,11 @@ Object {
       null,
     ],
   ],
-  "type": "table",
 }
 `;
 
 exports[`processTableData basic processing should read header and two rows 1`] = `
 Object {
-  "columnMap": Object {},
   "columns": Array [
     Object {
       "text": "a",
@@ -61,6 +58,5 @@ Object {
       6,
     ],
   ],
-  "type": "table",
 }
 `;

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

+ 3 - 1
packages/grafana-ui/src/utils/index.ts

@@ -1,9 +1,11 @@
 export * from './processTimeSeries';
-export * from './singlestat';
+export * from './processTableData';
 export * from './valueFormats/valueFormats';
 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';

+ 39 - 1
packages/grafana-ui/src/utils/processTableData.test.ts

@@ -1,4 +1,4 @@
-import { parseCSV } from './processTableData';
+import { parseCSV, toTableData } from './processTableData';
 
 describe('processTableData', () => {
   describe('basic processing', () => {
@@ -18,3 +18,41 @@ describe('processTableData', () => {
     });
   });
 });
+
+describe('toTableData', () => {
+  it('converts timeseries to table skipping nulls', () => {
+    const input1 = {
+      target: 'Field Name',
+      datapoints: [[100, 1], [200, 2]],
+    };
+    const input2 = {
+      // without target
+      target: '',
+      datapoints: [[100, 1], [200, 2]],
+    };
+    const data = toTableData([null, input1, input2, null, null]);
+    expect(data.length).toBe(2);
+    expect(data[0].columns[0].text).toBe(input1.target);
+    expect(data[0].rows).toBe(input1.datapoints);
+
+    // Default name
+    expect(data[1].columns[0].text).toEqual('Value');
+  });
+
+  it('keeps tableData unchanged', () => {
+    const input = {
+      columns: [{ text: 'A' }, { text: 'B' }, { text: 'C' }],
+      rows: [[100, 'A', 1], [200, 'B', 2], [300, 'C', 3]],
+    };
+    const data = toTableData([null, input, null, null]);
+    expect(data.length).toBe(1);
+    expect(data[0]).toBe(input);
+  });
+
+  it('supports null values OK', () => {
+    expect(toTableData([null, null, null, null])).toEqual([]);
+    expect(toTableData(undefined)).toEqual([]);
+    expect(toTableData((null as unknown) as any[])).toEqual([]);
+    expect(toTableData([])).toEqual([]);
+  });
+});

+ 40 - 7
packages/grafana-ui/src/utils/processTableData.ts

@@ -3,7 +3,7 @@ import isNumber from 'lodash/isNumber';
 import Papa, { ParseError, ParseMeta } from 'papaparse';
 
 // Types
-import { TableData, Column } from '../types';
+import { TableData, Column, TimeSeries } from '../types';
 
 // Subset of all parse options
 export interface TableParseOptions {
@@ -70,8 +70,6 @@ export function matchRowSizes(table: TableData): TableData {
   return {
     columns,
     rows: fixedRows,
-    type: table.type,
-    columnMap: table.columnMap,
   };
 }
 
@@ -118,8 +116,6 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
     return {
       columns: [],
       rows: [],
-      type: 'table',
-      columnMap: {},
     };
   }
 
@@ -130,11 +126,48 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
   return matchRowSizes({
     columns: makeColumns(header),
     rows: results.data,
-    type: 'table',
-    columnMap: {},
   });
 }
 
+function convertTimeSeriesToTableData(timeSeries: TimeSeries): TableData {
+  return {
+    columns: [
+      {
+        text: timeSeries.target || 'Value',
+        unit: timeSeries.unit,
+      },
+      {
+        text: 'Time',
+        type: 'time',
+        unit: 'dateTimeAsIso',
+      },
+    ],
+    rows: timeSeries.datapoints,
+  };
+}
+
+export const isTableData = (data: any): data is TableData => data && data.hasOwnProperty('columns');
+
+export const toTableData = (results?: any[]): TableData[] => {
+  if (!results) {
+    return [];
+  }
+
+  return results
+    .filter(d => !!d)
+    .map(data => {
+      if (data.hasOwnProperty('columns')) {
+        return data as TableData;
+      }
+      if (data.hasOwnProperty('datapoints')) {
+        return convertTimeSeriesToTableData(data);
+      }
+      // TODO, try to convert JSON to table?
+      console.warn('Can not convert', data);
+      throw new Error('Unsupported data format');
+    });
+};
+
 export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
   if (isNumber(sortIndex)) {
     const copy = {

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

@@ -4,17 +4,36 @@ import isNumber from 'lodash/isNumber';
 import { colors } from './colors';
 
 // Types
-import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
+import { TimeSeriesVMs, NullValueMode, TimeSeriesValue, TableData } from '../types';
 
 interface Options {
-  timeSeries: TimeSeries[];
+  data: TableData[];
+  xColumn?: number; // Time (or null to guess)
+  yColumn?: number; // Value (or null to guess)
   nullValueMode: NullValueMode;
 }
 
-export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
-  const vmSeries = timeSeries.map((item, index) => {
+// NOTE: this should move to processTableData.ts
+// I left it as is so the merge changes are more clear.
+export function processTimeSeries({ data, xColumn, yColumn, nullValueMode }: Options): TimeSeriesVMs {
+  const vmSeries = data.map((item, index) => {
+    if (!isNumber(xColumn)) {
+      xColumn = 1; // Default timeseries colum.  TODO, find first time field!
+    }
+    if (!isNumber(yColumn)) {
+      yColumn = 0; // TODO, find first non-time field
+    }
+
+    // TODO? either % or throw error?
+    if (xColumn >= item.columns.length) {
+      throw new Error('invalid colum: ' + xColumn);
+    }
+    if (yColumn >= item.columns.length) {
+      throw new Error('invalid colum: ' + yColumn);
+    }
+
     const colorIndex = index % colors.length;
-    const label = item.target;
+    const label = item.columns[yColumn].text;
     const result = [];
 
     // stat defaults
@@ -42,9 +61,9 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
     let previousValue = 0;
     let previousDeltaUp = true;
 
-    for (let i = 0; i < item.datapoints.length; i++) {
-      currentValue = item.datapoints[i][0];
-      currentTime = item.datapoints[i][1];
+    for (let i = 0; i < item.rows.length; i++) {
+      currentValue = item.rows[i][yColumn];
+      currentTime = item.rows[i][xColumn];
 
       if (typeof currentTime !== 'number') {
         continue;
@@ -95,7 +114,7 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
           if (previousValue > currentValue) {
             // counter reset
             previousDeltaUp = false;
-            if (i === item.datapoints.length - 1) {
+            if (i === item.rows.length - 1) {
               // reset on last
               delta += currentValue;
             }

+ 0 - 33
packages/grafana-ui/src/utils/singlestat.ts

@@ -1,33 +0,0 @@
-import { PanelData, NullValueMode, SingleStatValueInfo } from '../types';
-import { processTimeSeries } from './processTimeSeries';
-
-export interface SingleStatProcessingOptions {
-  panelData: PanelData;
-  stat: string;
-}
-
-//
-// This is a temporary thing, waiting for a better data model and maybe unification between time series & table data
-//
-export function processSingleStatPanelData(options: SingleStatProcessingOptions): SingleStatValueInfo[] {
-  const { panelData, stat } = options;
-
-  if (panelData.timeSeries) {
-    const timeSeries = processTimeSeries({
-      timeSeries: panelData.timeSeries,
-      nullValueMode: NullValueMode.Null,
-    });
-
-    return timeSeries.map((series, index) => {
-      const value = stat !== 'name' ? series.stats[stat] : series.label;
-
-      return {
-        value: value,
-      };
-    });
-  } else if (panelData.tableData) {
-    throw { message: 'Panel data not supported' };
-  }
-
-  return [];
-}

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

+ 21 - 12
pkg/api/api.go

@@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() {
 	reqGrafanaAdmin := middleware.ReqGrafanaAdmin
 	reqEditorRole := middleware.ReqEditorRole
 	reqOrgAdmin := middleware.ReqOrgAdmin
+	reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin)
 	redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
 	redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
 	quota := middleware.Quota(hs.QuotaService)
@@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/org/users", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/new", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/invite", reqOrgAdmin, hs.Index)
-	r.Get("/org/teams", reqOrgAdmin, hs.Index)
-	r.Get("/org/teams/*", reqOrgAdmin, hs.Index)
+	r.Get("/org/teams", reqCanAccessTeams, hs.Index)
+	r.Get("/org/teams/*", reqCanAccessTeams, hs.Index)
 	r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
 	r.Get("/dashboard/import/", reqSignedIn, hs.Index)
 	r.Get("/configuration", reqGrafanaAdmin, hs.Index)
@@ -133,6 +134,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)
@@ -150,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() {
 
 		// team (admin permission required)
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
-			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam))
-			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam))
-			teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID))
+			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam))
+			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
+			teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID))
 			teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
-			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
-			teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
-			teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences))
-			teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences))
-		}, reqOrgAdmin)
+			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
+			teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember))
+			teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences))
+			teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences))
+		}, reqCanAccessTeams)
 
 		// team without requirement of user to be org admin
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
 			teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
-			teamsRoute.Get("/search", Wrap(SearchTeams))
+			teamsRoute.Get("/search", Wrap(hs.SearchTeams))
 		})
 
 		// org information available to all users.
@@ -262,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() {
 		apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
 			folderRoute.Get("/", Wrap(GetFolders))
 			folderRoute.Get("/id/:id", Wrap(GetFolderByID))
-			folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder))
+			folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder))
 
 			folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
 				folderUidRoute.Get("/", Wrap(GetFolderByUID))
@@ -375,6 +380,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() {

+ 11 - 1
pkg/api/dashboard.go

@@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
 
 	dash := cmd.GetDashboardModel()
 
-	if dash.Id == 0 && dash.Uid == "" {
+	newDashboard := dash.Id == 0 && dash.Uid == ""
+	if newDashboard {
 		limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
 		if err != nil {
 			return Error(500, "failed to get quota", err)
@@ -276,6 +277,15 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
 		return Error(500, "Failed to save dashboard", err)
 	}
 
+	if hs.Cfg.EditorsCanAdmin && newDashboard {
+		inFolder := cmd.FolderId > 0
+		err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
+		if err != nil {
+			hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
+			return Error(500, "Failed to make user admin of dashboard", err)
+		}
+	}
+
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	return JSON(200, util.DynMap{
 		"status":  "success",

+ 2 - 0
pkg/api/dashboard_test.go

@@ -974,6 +974,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
 
 		hs := HTTPServer{
 			Bus: bus.GetBus(),
+			Cfg: setting.NewCfg(),
 		}
 
 		sc := setupScenarioContext(url)
@@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin
 		defer bus.ClearBusHandlers()
 
 		hs := HTTPServer{
+			Cfg: setting.NewCfg(),
 			Bus: bus.GetBus(),
 		}
 

+ 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"`
+}

+ 8 - 1
pkg/api/folder.go

@@ -54,13 +54,20 @@ func GetFolderByID(c *m.ReqContext) Response {
 	return JSON(200, toFolderDto(g, folder))
 }
 
-func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
+func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
 	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
 	err := s.CreateFolder(&cmd)
 	if err != nil {
 		return toFolderError(err)
 	}
 
+	if hs.Cfg.EditorsCanAdmin {
+		if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil {
+			hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
+			return Error(500, "Failed to make user admin of folder", err)
+		}
+	}
+
 	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
 	return JSON(200, toFolderDto(g, cmd.Result))
 }

+ 7 - 1
pkg/api/folder_test.go

@@ -9,6 +9,7 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/setting"
 
 	. "github.com/smartystreets/goconvey/convey"
 )
@@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 
+		hs := HTTPServer{
+			Bus: bus.GetBus(),
+			Cfg: setting.NewCfg(),
+		}
+
 		sc := setupScenarioContext(url)
 		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
 
-			return CreateFolder(c, cmd)
+			return hs.CreateFolder(c, cmd)
 		})
 
 		origNewFolderService := dashboards.NewFolderService

+ 1 - 1
pkg/api/frontendsettings.go

@@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
 		"externalUserMngLinkUrl":     setting.ExternalUserMngLinkUrl,
 		"externalUserMngLinkName":    setting.ExternalUserMngLinkName,
 		"viewersCanEdit":             setting.ViewersCanEdit,
-		"editorsCanOwn":              hs.Cfg.EditorsCanOwn,
+		"editorsCanAdmin":            hs.Cfg.EditorsCanAdmin,
 		"disableSanitizeHtml":        hs.Cfg.DisableSanitizeHtml,
 		"buildInfo": map[string]interface{}{
 			"version":       setting.BuildVersion,

+ 21 - 0
pkg/api/index.go

@@ -327,6 +327,27 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
 		})
 	}
 
+	if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin {
+		cfgNode := &dtos.NavLink{
+			Id:       "cfg",
+			Text:     "Configuration",
+			SubTitle: "Organization: " + c.OrgName,
+			Icon:     "gicon gicon-cog",
+			Url:      setting.AppSubUrl + "/org/teams",
+			Children: []*dtos.NavLink{
+				{
+					Text:        "Teams",
+					Id:          "teams",
+					Description: "Manage org groups",
+					Icon:        "gicon gicon-team",
+					Url:         setting.AppSubUrl + "/org/teams",
+				},
+			},
+		}
+
+		data.NavTree = append(data.NavTree, cfgNode)
+	}
+
 	data.NavTree = append(data.NavTree, &dtos.NavLink{
 		Text:         "Help",
 		SubTitle:     fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),

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

+ 69 - 17
pkg/api/team.go

@@ -4,19 +4,38 @@ 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/teamguardian"
 	"github.com/grafana/grafana/pkg/util"
 )
 
 // POST /api/teams
-func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
+func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
 	cmd.OrgId = c.OrgId
-	if err := bus.Dispatch(&cmd); err != nil {
+
+	if c.OrgRole == m.ROLE_VIEWER {
+		return Error(403, "Not allowed to create team.", nil)
+	}
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNameTaken {
 			return Error(409, "Team name taken", err)
 		}
 		return Error(500, "Failed to create Team", err)
 	}
 
+	if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin {
+		addMemberCmd := m.AddTeamMemberCommand{
+			UserId:     c.SignedInUser.UserId,
+			OrgId:      cmd.OrgId,
+			TeamId:     cmd.Result.Id,
+			Permission: m.PERMISSION_ADMIN,
+		}
+
+		if err := hs.Bus.Dispatch(&addMemberCmd); err != nil {
+			c.Logger.Error("Could not add creator to team.", "error", err)
+		}
+	}
+
 	return JSON(200, &util.DynMap{
 		"teamId":  cmd.Result.Id,
 		"message": "Team created",
@@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
 }
 
 // PUT /api/teams/:teamId
-func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
+func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
 	cmd.OrgId = c.OrgId
 	cmd.Id = c.ParamsInt64(":teamId")
-	if err := bus.Dispatch(&cmd); err != nil {
+
+	if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to update team", err)
+	}
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNameTaken {
 			return Error(400, "Team name taken", err)
 		}
@@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
 }
 
 // DELETE /api/teams/:teamId
-func DeleteTeamByID(c *m.ReqContext) Response {
-	if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil {
+func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response {
+	orgId := c.OrgId
+	teamId := c.ParamsInt64(":teamId")
+	user := c.SignedInUser
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil {
+		return Error(403, "Not allowed to delete team", err)
+	}
+
+	if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Failed to delete Team. ID not found", nil)
 		}
-		return Error(500, "Failed to update Team", err)
+		return Error(500, "Failed to delete Team", err)
 	}
 	return Success("Team deleted")
 }
 
 // GET /api/teams/search
-func SearchTeams(c *m.ReqContext) Response {
+func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response {
 	perPage := c.QueryInt("perpage")
 	if perPage <= 0 {
 		perPage = 1000
@@ -59,12 +91,18 @@ func SearchTeams(c *m.ReqContext) Response {
 		page = 1
 	}
 
+	var userIdFilter int64
+	if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN {
+		userIdFilter = c.SignedInUser.UserId
+	}
+
 	query := m.SearchTeamsQuery{
-		OrgId: c.OrgId,
-		Query: c.Query("query"),
-		Name:  c.Query("name"),
-		Page:  page,
-		Limit: perPage,
+		OrgId:        c.OrgId,
+		Query:        c.Query("query"),
+		Name:         c.Query("name"),
+		UserIdFilter: userIdFilter,
+		Page:         page,
+		Limit:        perPage,
 	}
 
 	if err := bus.Dispatch(&query); err != nil {
@@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response {
 }
 
 // GET /api/teams/:teamId/preferences
-func GetTeamPreferences(c *m.ReqContext) Response {
-	return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"))
+func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response {
+	teamId := c.ParamsInt64(":teamId")
+	orgId := c.OrgId
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to view team preferences.", err)
+	}
+
+	return getPreferencesFor(orgId, 0, teamId)
 }
 
 // PUT /api/teams/:teamId/preferences
-func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
-	return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd)
+func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
+	teamId := c.ParamsInt64(":teamId")
+	orgId := c.OrgId
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to update team preferences.", err)
+	}
+
+	return updatePreferencesFor(orgId, 0, teamId, &dtoCmd)
 }

+ 49 - 5
pkg/api/team_members.go

@@ -4,6 +4,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/teamguardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
@@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response {
 }
 
 // POST /api/teams/:teamId/members
-func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
-	cmd.TeamId = c.ParamsInt64(":teamId")
+func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
 	cmd.OrgId = c.OrgId
+	cmd.TeamId = c.ParamsInt64(":teamId")
 
-	if err := bus.Dispatch(&cmd); err != nil {
+	if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to add team member", err)
+	}
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Team not found", nil)
 		}
@@ -50,9 +55,48 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
 	})
 }
 
+// PUT /:teamId/members/:userId
+func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response {
+	teamId := c.ParamsInt64(":teamId")
+	orgId := c.OrgId
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to update team member", err)
+	}
+
+	if c.OrgRole != m.ROLE_ADMIN {
+		cmd.ProtectLastAdmin = true
+	}
+
+	cmd.TeamId = teamId
+	cmd.UserId = c.ParamsInt64(":userId")
+	cmd.OrgId = orgId
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamMemberNotFound {
+			return Error(404, "Team member not found.", nil)
+		}
+		return Error(500, "Failed to update team member.", err)
+	}
+	return Success("Team member updated")
+}
+
 // DELETE /api/teams/:teamId/members/:userId
-func RemoveTeamMember(c *m.ReqContext) Response {
-	if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
+func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response {
+	orgId := c.OrgId
+	teamId := c.ParamsInt64(":teamId")
+	userId := c.ParamsInt64(":userId")
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to remove team member", err)
+	}
+
+	protectLastAdmin := false
+	if c.OrgRole != m.ROLE_ADMIN {
+		protectLastAdmin = true
+	}
+
+	if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Team not found", nil)
 		}

+ 8 - 2
pkg/api/team_test.go

@@ -3,6 +3,8 @@ package api
 import (
 	"testing"
 
+	"github.com/grafana/grafana/pkg/setting"
+
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/models"
@@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) {
 			TotalCount: 2,
 		}
 
+		hs := &HTTPServer{
+			Cfg: setting.NewCfg(),
+		}
+
 		Convey("When searching with no parameters", func() {
 			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
 				var sentLimit int
@@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) {
 					return nil
 				})
 
-				sc.handlerFunc = SearchTeams
+				sc.handlerFunc = hs.SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 				So(sentLimit, ShouldEqual, 1000)
@@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) {
 					return nil
 				})
 
-				sc.handlerFunc = SearchTeams
+				sc.handlerFunc = hs.SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
 
 				So(sentLimit, ShouldEqual, 10)

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

+ 17 - 0
pkg/middleware/auth.go

@@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler {
 		}
 	}
 }
+
+// AdminOrFeatureEnabled creates a middleware that allows access
+// if the signed in user is either an Org Admin or if the
+// feature flag is enabled.
+// Intended for when feature flags open up access to APIs that
+// are otherwise only available to admins.
+func AdminOrFeatureEnabled(enabled bool) macaron.Handler {
+	return func(c *m.ReqContext) {
+		if c.OrgRole == m.ROLE_ADMIN {
+			return
+		}
+
+		if !enabled {
+			accessForbidden(c)
+		}
+	}
+}

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

+ 19 - 14
pkg/models/team.go

@@ -7,9 +7,12 @@ import (
 
 // Typed errors
 var (
-	ErrTeamNotFound       = errors.New("Team not found")
-	ErrTeamNameTaken      = errors.New("Team name is taken")
-	ErrTeamMemberNotFound = errors.New("Team member not found")
+	ErrTeamNotFound                         = errors.New("Team not found")
+	ErrTeamNameTaken                        = errors.New("Team name is taken")
+	ErrTeamMemberNotFound                   = errors.New("Team member not found")
+	ErrLastTeamAdmin                        = errors.New("Not allowed to remove last admin")
+	ErrNotAllowedToUpdateTeam               = errors.New("User not allowed to update team")
+	ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org")
 )
 
 // Team model
@@ -59,22 +62,24 @@ type GetTeamsByUserQuery struct {
 }
 
 type SearchTeamsQuery struct {
-	Query string
-	Name  string
-	Limit int
-	Page  int
-	OrgId int64
+	Query        string
+	Name         string
+	Limit        int
+	Page         int
+	OrgId        int64
+	UserIdFilter int64
 
 	Result SearchTeamQueryResult
 }
 
 type TeamDTO struct {
-	Id          int64  `json:"id"`
-	OrgId       int64  `json:"orgId"`
-	Name        string `json:"name"`
-	Email       string `json:"email"`
-	AvatarUrl   string `json:"avatarUrl"`
-	MemberCount int64  `json:"memberCount"`
+	Id          int64          `json:"id"`
+	OrgId       int64          `json:"orgId"`
+	Name        string         `json:"name"`
+	Email       string         `json:"email"`
+	AvatarUrl   string         `json:"avatarUrl"`
+	MemberCount int64          `json:"memberCount"`
+	Permission  PermissionType `json:"permission"`
 }
 
 type SearchTeamQueryResult struct {

+ 32 - 20
pkg/models/team_member.go

@@ -12,11 +12,12 @@ var (
 
 // TeamMember model
 type TeamMember struct {
-	Id       int64
-	OrgId    int64
-	TeamId   int64
-	UserId   int64
-	External bool
+	Id         int64
+	OrgId      int64
+	TeamId     int64
+	UserId     int64
+	External   bool // Signals that the membership has been created by an external systems, such as LDAP
+	Permission PermissionType
 
 	Created time.Time
 	Updated time.Time
@@ -26,16 +27,26 @@ type TeamMember struct {
 // COMMANDS
 
 type AddTeamMemberCommand struct {
-	UserId   int64 `json:"userId" binding:"Required"`
-	OrgId    int64 `json:"-"`
-	TeamId   int64 `json:"-"`
-	External bool  `json:"-"`
+	UserId     int64          `json:"userId" binding:"Required"`
+	OrgId      int64          `json:"-"`
+	TeamId     int64          `json:"-"`
+	External   bool           `json:"-"`
+	Permission PermissionType `json:"-"`
+}
+
+type UpdateTeamMemberCommand struct {
+	UserId           int64          `json:"-"`
+	OrgId            int64          `json:"-"`
+	TeamId           int64          `json:"-"`
+	Permission       PermissionType `json:"permission"`
+	ProtectLastAdmin bool           `json:"-"`
 }
 
 type RemoveTeamMemberCommand struct {
-	OrgId  int64 `json:"-"`
-	UserId int64
-	TeamId int64
+	OrgId            int64 `json:"-"`
+	UserId           int64
+	TeamId           int64
+	ProtectLastAdmin bool `json:"-"`
 }
 
 // ----------------------
@@ -53,12 +64,13 @@ type GetTeamMembersQuery struct {
 // Projections and DTOs
 
 type TeamMemberDTO struct {
-	OrgId     int64    `json:"orgId"`
-	TeamId    int64    `json:"teamId"`
-	UserId    int64    `json:"userId"`
-	External  bool     `json:"-"`
-	Email     string   `json:"email"`
-	Login     string   `json:"login"`
-	AvatarUrl string   `json:"avatarUrl"`
-	Labels    []string `json:"labels"`
+	OrgId      int64          `json:"orgId"`
+	TeamId     int64          `json:"teamId"`
+	UserId     int64          `json:"userId"`
+	External   bool           `json:"-"`
+	Email      string         `json:"email"`
+	Login      string         `json:"login"`
+	AvatarUrl  string         `json:"avatarUrl"`
+	Labels     []string       `json:"labels"`
+	Permission PermissionType `json:"permission"`
 }

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

+ 15 - 0
pkg/services/alerting/notifiers/victorops.go

@@ -92,14 +92,29 @@ func (this *VictoropsNotifier) Notify(evalContext *alerting.EvalContext) error {
 		messageType = AlertStateRecovery
 	}
 
+	fields := make(map[string]interface{}, 0)
+	fieldLimitCount := 4
+	for index, evt := range evalContext.EvalMatches {
+		fields[evt.Metric] = evt.Value
+		if index > fieldLimitCount {
+			break
+		}
+	}
+
 	bodyJSON := simplejson.New()
 	bodyJSON.Set("message_type", messageType)
 	bodyJSON.Set("entity_id", evalContext.Rule.Name)
+	bodyJSON.Set("entity_display_name", evalContext.GetNotificationTitle())
 	bodyJSON.Set("timestamp", time.Now().Unix())
 	bodyJSON.Set("state_start_time", evalContext.StartTime.Unix())
 	bodyJSON.Set("state_message", evalContext.Rule.Message)
 	bodyJSON.Set("monitoring_tool", "Grafana v"+setting.BuildVersion)
 	bodyJSON.Set("alert_url", ruleUrl)
+	bodyJSON.Set("metrics", fields)
+
+	if evalContext.Error != nil {
+		bodyJSON.Set("error_message", evalContext.Error.Error())
+	}
 
 	if evalContext.ImagePublicUrl != "" {
 		bodyJSON.Set("image_url", evalContext.ImagePublicUrl)

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

+ 55 - 0
pkg/services/dashboards/acl_service.go

@@ -0,0 +1,55 @@
+package dashboards
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"time"
+)
+
+func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error {
+	rtEditor := models.ROLE_EDITOR
+	rtViewer := models.ROLE_VIEWER
+
+	items := []*models.DashboardAcl{
+		{
+			OrgId:       orgId,
+			DashboardId: dashboardId,
+			UserId:      userId,
+			Permission:  models.PERMISSION_ADMIN,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+		},
+	}
+
+	if setViewAndEditPermissions {
+		items = append(items,
+			&models.DashboardAcl{
+				OrgId:       orgId,
+				DashboardId: dashboardId,
+				Role:        &rtEditor,
+				Permission:  models.PERMISSION_EDIT,
+				Created:     time.Now(),
+				Updated:     time.Now(),
+			},
+			&models.DashboardAcl{
+				OrgId:       orgId,
+				DashboardId: dashboardId,
+				Role:        &rtViewer,
+				Permission:  models.PERMISSION_VIEW,
+				Created:     time.Now(),
+				Updated:     time.Now(),
+			},
+		)
+	}
+
+	aclCmd := &models.UpdateDashboardAclCommand{
+		DashboardId: dashboardId,
+		Items:       items,
+	}
+
+	if err := bus.Dispatch(aclCmd); err != nil {
+		return err
+	}
+
+	return nil
+}

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

+ 4 - 0
pkg/services/sqlstore/migrations/team_mig.go

@@ -54,4 +54,8 @@ func addTeamMigrations(mg *Migrator) {
 	mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
 		Name: "external", Type: DB_Bool, Nullable: true,
 	}))
+
+	mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
+		Name: "permission", Type: DB_SmallInt, Nullable: true,
+	}))
 }

+ 103 - 19
pkg/services/sqlstore/team.go

@@ -18,17 +18,30 @@ func init() {
 	bus.AddHandler("sql", GetTeamsByUser)
 
 	bus.AddHandler("sql", AddTeamMember)
+	bus.AddHandler("sql", UpdateTeamMember)
 	bus.AddHandler("sql", RemoveTeamMember)
 	bus.AddHandler("sql", GetTeamMembers)
 }
 
+func getTeamSearchSqlBase() string {
+	return `SELECT
+		team.id as id,
+		team.org_id,
+		team.name as name,
+		team.email as email,
+		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count,
+		team_member.permission
+		FROM team as team
+		INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? `
+}
+
 func getTeamSelectSqlBase() string {
 	return `SELECT
 		team.id as id,
 		team.org_id,
 		team.name as name,
 		team.email as email,
-		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count
+		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count 
 		FROM team as team `
 }
 
@@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
 // DeleteTeam will delete a team, its member and any permissions connected to the team
 func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
+		if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
 		}
 
 		deletes := []string{
@@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) {
 	if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
 		return false, err
 	} else if len(res) != 1 {
-		return false, nil
+		return false, m.ErrTeamNotFound
 	}
 
 	return true, nil
@@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 
-	sql.WriteString(getTeamSelectSqlBase())
+	if query.UserIdFilter > 0 {
+		sql.WriteString(getTeamSearchSqlBase())
+		params = append(params, query.UserIdFilter)
+	} else {
+		sql.WriteString(getTeamSelectSqlBase())
+	}
 	sql.WriteString(` WHERE team.org_id = ?`)
 
 	params = append(params, query.OrgId)
@@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 			return m.ErrTeamMemberAlreadyAdded
 		}
 
-		if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
+		if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
 		}
 
 		entity := m.TeamMember{
-			OrgId:    cmd.OrgId,
-			TeamId:   cmd.TeamId,
-			UserId:   cmd.UserId,
-			External: cmd.External,
-			Created:  time.Now(),
-			Updated:  time.Now(),
+			OrgId:      cmd.OrgId,
+			TeamId:     cmd.TeamId,
+			UserId:     cmd.UserId,
+			External:   cmd.External,
+			Created:    time.Now(),
+			Updated:    time.Now(),
+			Permission: cmd.Permission,
 		}
 
 		_, err := sess.Insert(&entity)
@@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 	})
 }
 
+func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) {
+	rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?`
+	var member m.TeamMember
+	exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member)
+
+	if err != nil {
+		return member, err
+	}
+	if !exists {
+		return member, m.ErrTeamMemberNotFound
+	}
+
+	return member, nil
+}
+
+// UpdateTeamMember updates a team member
+func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
+		if err != nil {
+			return err
+		}
+
+		if cmd.ProtectLastAdmin {
+			_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
+			if err != nil {
+				return err
+			}
+		}
+
+		if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store
+			cmd.Permission = 0
+		}
+
+		member.Permission = cmd.Permission
+		_, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member)
+
+		return err
+	})
+}
+
 // RemoveTeamMember removes a member from a team
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
+		if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
+		}
+
+		if cmd.ProtectLastAdmin {
+			_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
+			if err != nil {
+				return err
+			}
 		}
 
 		var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
@@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 	})
 }
 
+func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) {
+	rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?"
+	userIds := []*int64{}
+	err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds)
+	if err != nil {
+		return false, err
+	}
+
+	isAdmin := false
+	for _, adminId := range userIds {
+		if userId == *adminId {
+			isAdmin = true
+			break
+		}
+	}
+
+	if isAdmin && len(userIds) == 1 {
+		return true, m.ErrLastTeamAdmin
+	}
+
+	return false, err
+}
+
 // GetTeamMembers return a list of members for the specified team
 func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	query.Result = make([]*m.TeamMemberDTO, 0)
@@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	if query.External {
 		sess.Where("team_member.external=?", dialect.BooleanStr(true))
 	}
-	sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external")
+	sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission")
 	sess.Asc("user.login", "user.email")
 
 	err := sess.Find(&query.Result)

+ 93 - 0
pkg/services/sqlstore/team_test.go

@@ -75,6 +75,72 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				So(q2.Result[0].External, ShouldEqual, true)
 			})
 
+			Convey("Should be able to update users in a team", func() {
+				userId := userIds[0]
+				team := group1.Result
+				addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userId}
+				err = AddTeamMember(&addMemberCmd)
+				So(err, ShouldBeNil)
+
+				qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qBeforeUpdate)
+				So(err, ShouldBeNil)
+				So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
+
+				err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
+					UserId:     userId,
+					OrgId:      testOrgId,
+					TeamId:     team.Id,
+					Permission: m.PERMISSION_ADMIN,
+				})
+
+				So(err, ShouldBeNil)
+
+				qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qAfterUpdate)
+				So(err, ShouldBeNil)
+				So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+			})
+
+			Convey("Should default to member permission level when updating a user with invalid permission level", func() {
+				userID := userIds[0]
+				team := group1.Result
+				addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID}
+				err = AddTeamMember(&addMemberCmd)
+				So(err, ShouldBeNil)
+
+				qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qBeforeUpdate)
+				So(err, ShouldBeNil)
+				So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
+
+				invalidPermissionLevel := m.PERMISSION_EDIT
+				err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
+					UserId:     userID,
+					OrgId:      testOrgId,
+					TeamId:     team.Id,
+					Permission: invalidPermissionLevel,
+				})
+
+				So(err, ShouldBeNil)
+
+				qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qAfterUpdate)
+				So(err, ShouldBeNil)
+				So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0)
+			})
+
+			Convey("Shouldn't be able to update a user not in the team.", func() {
+				err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
+					UserId:     1,
+					OrgId:      testOrgId,
+					TeamId:     group1.Result.Id,
+					Permission: m.PERMISSION_ADMIN,
+				})
+
+				So(err, ShouldEqual, m.ErrTeamMemberNotFound)
+			})
+
 			Convey("Should be able to search for teams", func() {
 				query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
 				err = SearchTeams(query)
@@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				So(len(q2.Result), ShouldEqual, 0)
 			})
 
+			Convey("When ProtectLastAdmin is set to true", func() {
+				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN})
+				So(err, ShouldBeNil)
+
+				Convey("A user should not be able to remove the last admin", func() {
+					err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
+					So(err, ShouldEqual, m.ErrLastTeamAdmin)
+				})
+
+				Convey("A user should be able to remove an admin if there are other admins", func() {
+					AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
+					err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
+					So(err, ShouldEqual, nil)
+				})
+
+				Convey("A user should not be able to remove the admin permission for the last admin", func() {
+					err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
+					So(err, ShouldEqual, m.ErrLastTeamAdmin)
+				})
+
+				Convey("A user should be able to remove the admin permission if there are other admins", func() {
+					AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
+					err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
+					So(err, ShouldEqual, nil)
+				})
+			})
+
 			Convey("Should be able to remove a group with users and permissions", func() {
 				groupId := group2.Result.Id
 				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})

+ 34 - 0
pkg/services/teamguardian/team.go

@@ -0,0 +1,34 @@
+package teamguardian
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error {
+	if user.OrgRole == m.ROLE_ADMIN {
+		return nil
+	}
+
+	if user.OrgId != orgId {
+		return m.ErrNotAllowedToUpdateTeamInDifferentOrg
+	}
+
+	cmd := m.GetTeamMembersQuery{
+		OrgId:  orgId,
+		TeamId: teamId,
+		UserId: user.UserId,
+	}
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		return err
+	}
+
+	for _, member := range cmd.Result {
+		if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN {
+			return nil
+		}
+	}
+
+	return m.ErrNotAllowedToUpdateTeam
+}

+ 87 - 0
pkg/services/teamguardian/teams_test.go

@@ -0,0 +1,87 @@
+package teamguardian
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+	"testing"
+)
+
+func TestUpdateTeam(t *testing.T) {
+	Convey("Updating a team", t, func() {
+		bus.ClearBusHandlers()
+
+		admin := m.SignedInUser{
+			UserId:  1,
+			OrgId:   1,
+			OrgRole: m.ROLE_ADMIN,
+		}
+		editor := m.SignedInUser{
+			UserId:  2,
+			OrgId:   1,
+			OrgRole: m.ROLE_EDITOR,
+		}
+		testTeam := m.Team{
+			Id:    1,
+			OrgId: 1,
+		}
+
+		Convey("Given an editor and a team he isn't a member of", func() {
+			Convey("Should not be able to update the team", func() {
+				bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
+					cmd.Result = []*m.TeamMemberDTO{}
+					return nil
+				})
+
+				err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
+				So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam)
+			})
+		})
+
+		Convey("Given an editor and a team he is an admin in", func() {
+			Convey("Should be able to update the team", func() {
+				bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
+					cmd.Result = []*m.TeamMemberDTO{{
+						OrgId:      testTeam.OrgId,
+						TeamId:     testTeam.Id,
+						UserId:     editor.UserId,
+						Permission: m.PERMISSION_ADMIN,
+					}}
+					return nil
+				})
+
+				err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
+				So(err, ShouldBeNil)
+			})
+		})
+
+		Convey("Given an editor and a team in another org", func() {
+			testTeamOtherOrg := m.Team{
+				Id:    1,
+				OrgId: 2,
+			}
+
+			Convey("Shouldn't be able to update the team", func() {
+				bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
+					cmd.Result = []*m.TeamMemberDTO{{
+						OrgId:      testTeamOtherOrg.OrgId,
+						TeamId:     testTeamOtherOrg.Id,
+						UserId:     editor.UserId,
+						Permission: m.PERMISSION_ADMIN,
+					}}
+					return nil
+				})
+
+				err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor)
+				So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg)
+			})
+		})
+
+		Convey("Given an org admin and a team", func() {
+			Convey("Should be able to update the team", func() {
+				err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin)
+				So(err, ShouldBeNil)
+			})
+		})
+	})
+}

+ 20 - 3
pkg/setting/setting.go

@@ -239,8 +239,13 @@ type Cfg struct {
 	LoginMaxLifetimeDays         int
 	TokenRotationIntervalMinutes int
 
-	// User
-	EditorsCanOwn bool
+	// Dataproxy
+	SendUserHeader bool
+
+	// DistributedCache
+	RemoteCacheOptions *RemoteCacheOptions
+
+	EditorsCanAdmin bool
 }
 
 type CommandLineArgs struct {
@@ -601,6 +606,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")
@@ -663,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
 	ExternalUserMngInfo = users.Key("external_manage_info").String()
 	ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
-	cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false)
+	cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)
 
 	// auth
 	auth := iniFile.Section("auth")
@@ -781,9 +787,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{}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác