Browse Source

Merge branch 'master' into bump-webpack

# Conflicts:
#	package.json
#	yarn.lock
Johannes Schill 6 years ago
parent
commit
7dec4a9f2d
100 changed files with 3712 additions and 969 deletions
  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
             name: postgres integration tests
             command: './scripts/circle-test-postgres.sh'
             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:
   codespell:
     docker:
     docker:
       - image: circleci/python
       - image: circleci/python
@@ -308,7 +322,7 @@ jobs:
 
 
   deploy-enterprise-master:
   deploy-enterprise-master:
     docker:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
     steps:
       - attach_workspace:
       - attach_workspace:
           at: .
           at: .
@@ -331,7 +345,7 @@ jobs:
 
 
   deploy-enterprise-release:
   deploy-enterprise-release:
     docker:
     docker:
-    - image: grafana/grafana-ci-deploy:1.2.0
+    - image: grafana/grafana-ci-deploy:1.2.1
     steps:
     steps:
       - checkout
       - checkout
       - attach_workspace:
       - attach_workspace:
@@ -364,7 +378,7 @@ jobs:
 
 
   deploy-master:
   deploy-master:
     docker:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
     steps:
       - attach_workspace:
       - attach_workspace:
           at: .
           at: .
@@ -395,7 +409,7 @@ jobs:
 
 
   deploy-release:
   deploy-release:
     docker:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
     steps:
       - checkout
       - checkout
       - attach_workspace:
       - attach_workspace:
@@ -545,6 +559,8 @@ workflows:
             filters: *filter-not-release-or-master
             filters: *filter-not-release-or-master
         - postgres-integration-test:
         - postgres-integration-test:
             filters: *filter-not-release-or-master
             filters: *filter-not-release-or-master
+        - cache-server-test:
+            filters: *filter-not-release-or-master
         - grafana-docker-pr:
         - grafana-docker-pr:
             requires:
             requires:
               - build
               - build
@@ -554,4 +570,5 @@ workflows:
               - gometalinter
               - gometalinter
               - mysql-integration-test
               - mysql-integration-test
               - postgres-integration-test
               - postgres-integration-test
+              - cache-server-test
             filters: *filter-not-release-or-master
             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)
 * **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**: `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)
 * **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
 ### Bug Fixes
 * **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506)
 * **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)
 * **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)
 * **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)
 # 6.0.1 (2019-03-06)
 
 
 ### Bug Fixes
 ### 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
 # For "sqlite3" only. cache mode setting used for connecting to the database
 cache_mode = private
 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 #############################
 [session]
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
 # 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)
 # How long the data proxy should wait before timing out default is 30 (seconds)
 timeout = 30
 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 ###########################
 [analytics]
 [analytics]
 # Server reporting, sends usage counters to stats.grafana.org every 24 hours.
 # Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@@ -245,7 +259,7 @@ external_manage_info =
 viewers_can_edit = false
 viewers_can_edit = false
 
 
 # Editors can administrate dashboard, folders and teams they create
 # Editors can administrate dashboard, folders and teams they create
-editors_can_own = false
+editors_can_admin = false
 
 
 [auth]
 [auth]
 # Login cookie name
 # 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)
 # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
 ;cache_mode = private
 ;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 ####################################
 [session]
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
 # 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)
 # How long the data proxy should wait before timing out default is 30 (seconds)
 ;timeout = 30
 ;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 ####################################
 [analytics]
 [analytics]
 # Server reporting, sends usage counters to stats.grafana.org every 24 hours.
 # Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@@ -225,7 +239,7 @@ log_queries =
 ;viewers_can_edit = false
 ;viewers_can_edit = false
 
 
 # Editors can administrate dashboard, folders and teams they create
 # Editors can administrate dashboard, folders and teams they create
-;editors_can_own = false
+;editors_can_admin = false
 
 
 [auth]
 [auth]
 # Login cookie name
 # Login cookie name

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

@@ -1,4 +1,4 @@
-  memcached:
+  redis:
     image: redis:latest
     image: redis:latest
     ports:
     ports:
       - "6379:6379"
       - "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)
 * [DataDog](https://grafana.com/plugins/grafana-datadog-datasource)
 * [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource)
 * [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource)
 * [New Relic](https://grafana.com/plugins/grafana-newrelic-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
 ## 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}
 {"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"}
 {"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
 Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events
 to grafana log.
 to grafana log.
-<hr />
 
 
 <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)
 For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
 Defaults to private.
 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 />
 <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.
 Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
 Defaults to `false`.
 Defaults to `false`.
 
 
+### editors_can_admin
+
+Editors can administrate dashboards, folders and teams they create.
+Defaults to `false`.
+
 ### login_hint
 ### login_hint
 
 
 Text used as placeholder text on login page for login/username input.
 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 />
 <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]
 ## [analytics]
 
 
 ### reporting_enabled
 ### 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.
 - 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.
 - **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
 ## Viewer Role
 
 
 - View any dashboard. This can be disabled on specific folders and dashboards.
 - 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
 ```bash
 servers = ['foo()bar BAZ', 'test2']
 servers = ['foo()bar BAZ', 'test2']
-String to interpolate: '${servers:lucene}'
+String to interpolate: '${servers:percentencode}'
 Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2'
 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/jquery": "^1.10.35",
     "@types/node": "^8.0.31",
     "@types/node": "^8.0.31",
     "@types/papaparse": "^4.5.9",
     "@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-grid-layout": "^0.16.6",
     "@types/react-select": "^2.0.4",
     "@types/react-select": "^2.0.4",
     "@types/react-transition-group": "^2.0.15",
     "@types/react-transition-group": "^2.0.15",
     "@types/react-virtualized": "^9.18.12",
     "@types/react-virtualized": "^9.18.12",
+    "@types/clipboard": "^2.0.1",
     "angular-mocks": "1.6.6",
     "angular-mocks": "1.6.6",
     "autoprefixer": "^9.4.10",
     "autoprefixer": "^9.4.10",
     "axios": "^0.18.0",
     "axios": "^0.18.0",
@@ -177,7 +178,7 @@
     "baron": "^3.0.3",
     "baron": "^3.0.3",
     "brace": "^0.10.0",
     "brace": "^0.10.0",
     "classnames": "^2.2.6",
     "classnames": "^2.2.6",
-    "clipboard": "^1.7.1",
+    "clipboard": "^2.0.4",
     "d3": "^4.11.0",
     "d3": "^4.11.0",
     "d3-scale-chromatic": "^1.3.0",
     "d3-scale-chromatic": "^1.3.0",
     "eventemitter3": "^2.0.3",
     "eventemitter3": "^2.0.3",
@@ -193,8 +194,8 @@
     "prismjs": "^1.6.0",
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.2",
     "prop-types": "^15.6.2",
     "rc-cascader": "^0.14.0",
     "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-grid-layout": "0.16.6",
     "react-highlight-words": "0.11.0",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
     "react-popper": "^1.3.0",
@@ -221,7 +222,7 @@
   },
   },
   "resolutions": {
   "resolutions": {
     "caniuse-db": "1.0.30000772",
     "caniuse-db": "1.0.30000772",
-    "**/@types/react": "16.7.6"
+    "**/@types/react": "16.8.8"
   },
   },
   "workspaces": {
   "workspaces": {
     "packages": [
     "packages": [

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

@@ -25,10 +25,10 @@
     "lodash": "^4.17.10",
     "lodash": "^4.17.10",
     "moment": "^2.22.2",
     "moment": "^2.22.2",
     "papaparse": "^4.6.3",
     "papaparse": "^4.6.3",
-    "react": "^16.6.3",
+    "react": "^16.8.4",
     "react-color": "^2.17.0",
     "react-color": "^2.17.0",
     "react-custom-scrollbars": "^4.2.1",
     "react-custom-scrollbars": "^4.2.1",
-    "react-dom": "^16.6.3",
+    "react-dom": "^16.8.4",
     "react-highlight-words": "0.11.0",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
     "react-popper": "^1.3.0",
     "react-transition-group": "^2.2.1",
     "react-transition-group": "^2.2.1",
@@ -48,7 +48,7 @@
     "@types/lodash": "^4.14.119",
     "@types/lodash": "^4.14.119",
     "@types/node": "^10.12.18",
     "@types/node": "^10.12.18",
     "@types/papaparse": "^4.5.9",
     "@types/papaparse": "^4.5.9",
-    "@types/react": "^16.7.6",
+    "@types/react": "^16.8.8",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-test-renderer": "^16.0.3",
     "@types/react-test-renderer": "^16.0.3",
     "@types/react-transition-group": "^2.0.15",
     "@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 { storiesOf } from '@storybook/react';
-import { number, text } from '@storybook/addon-knobs';
+import { number, text, boolean } from '@storybook/addon-knobs';
 import { BarGauge } from './BarGauge';
 import { BarGauge } from './BarGauge';
+import { VizOrientation } from '../../types';
 import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
 import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
 import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
 import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
 
 
@@ -15,6 +16,8 @@ const getKnobs = () => {
     threshold2Color: text('threshold2Color', 'red'),
     threshold2Color: text('threshold2Color', 'red'),
     unit: text('unit', 'ms'),
     unit: text('unit', 'ms'),
     decimals: number('decimals', 1),
     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.addDecorator(withCenteredStory);
 
 
-BarGaugeStories.add('Vertical, with basic thresholds', () => {
+BarGaugeStories.add('Simple with basic thresholds', () => {
   const {
   const {
     value,
     value,
     minValue,
     minValue,
@@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
     threshold2Value,
     threshold2Value,
     unit,
     unit,
     decimals,
     decimals,
+    horizontal,
+    lcd,
   } = getKnobs();
   } = getKnobs();
 
 
   return renderComponentWithTheme(BarGauge, {
   return renderComponentWithTheme(BarGauge, {
-    width: 200,
-    height: 400,
+    width: 700,
+    height: 700,
     value: value,
     value: value,
     minValue: minValue,
     minValue: minValue,
     maxValue: maxValue,
     maxValue: maxValue,
@@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
     prefix: '',
     prefix: '',
     postfix: '',
     postfix: '',
     decimals: decimals,
     decimals: decimals,
+    orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical,
+    displayMode: lcd ? 'lcd' : 'simple',
     thresholds: [
     thresholds: [
       { index: 0, value: -Infinity, color: 'green' },
       { index: 0, value: -Infinity, color: 'green' },
       { index: 1, value: threshold1Value, color: threshold1Color },
       { 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 setup = (propOverrides?: object) => {
   const props: Props = {
   const props: Props = {
     maxValue: 100,
     maxValue: 100,
-    valueMappings: [],
     minValue: 0,
     minValue: 0,
-    prefix: '',
-    suffix: '',
+    displayMode: 'basic',
     thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
     thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
-    unit: 'none',
     height: 300,
     height: 300,
     width: 300,
     width: 300,
-    value: 25,
-    decimals: 0,
+    value: {
+      text: '25',
+      numeric: 25,
+    },
     theme: getTheme(),
     theme: getTheme(),
     orientation: VizOrientation.Horizontal,
     orientation: VizOrientation.Horizontal,
   };
   };

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

@@ -1,55 +1,54 @@
 // Library
 // Library
-import React, { PureComponent, CSSProperties } from 'react';
+import React, { PureComponent, CSSProperties, ReactNode } from 'react';
 import tinycolor from 'tinycolor2';
 import tinycolor from 'tinycolor2';
 
 
 // Utils
 // Utils
-import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
+import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils';
 
 
 // Types
 // Types
-import { Themeable, TimeSeriesValue, Threshold, ValueMapping, VizOrientation } from '../../types';
+import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types';
 
 
 const BAR_SIZE_RATIO = 0.8;
 const BAR_SIZE_RATIO = 0.8;
 
 
 export interface Props extends Themeable {
 export interface Props extends Themeable {
   height: number;
   height: number;
-  unit: string;
   width: number;
   width: number;
   thresholds: Threshold[];
   thresholds: Threshold[];
-  valueMappings: ValueMapping[];
-  value: TimeSeriesValue;
+  value: DisplayValue;
   maxValue: number;
   maxValue: number;
   minValue: number;
   minValue: number;
   orientation: VizOrientation;
   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> {
 export class BarGauge extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
   static defaultProps: Partial<Props> = {
     maxValue: 100,
     maxValue: 100,
     minValue: 0,
     minValue: 0,
-    value: 100,
-    unit: 'none',
+    value: {
+      text: '100',
+      numeric: 100,
+    },
+    displayMode: 'lcd',
     orientation: VizOrientation.Horizontal,
     orientation: VizOrientation.Horizontal,
     thresholds: [],
     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 {
   getValueColors(): BarColors {
     const { thresholds, theme, value } = this.props;
     const { thresholds, theme, value } = this.props;
 
 
-    const activeThreshold = getThresholdForValue(thresholds, value);
+    const activeThreshold = getThresholdForValue(thresholds, value.numeric);
 
 
     if (activeThreshold !== null) {
     if (activeThreshold !== null) {
       const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
       const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
@@ -57,41 +56,19 @@ export class BarGauge extends PureComponent<Props> {
       return {
       return {
         value: color,
         value: color,
         border: color,
         border: color,
-        bar: tinycolor(color)
-          .setAlpha(0.3)
+        background: tinycolor(color)
+          .setAlpha(0.15)
           .toRgbString(),
           .toRgbString(),
       };
       };
     }
     }
 
 
     return {
     return {
       value: getColorFromHexRgbOrName('gray', theme.type),
       value: getColorFromHexRgbOrName('gray', theme.type),
-      bar: getColorFromHexRgbOrName('gray', theme.type),
+      background: getColorFromHexRgbOrName('gray', theme.type),
       border: 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 {
   getValueStyles(value: string, color: string, width: number): CSSProperties {
     const guess = width / (value.length * 1.1);
     const guess = width / (value.length * 1.1);
     const fontSize = Math.min(Math.max(guess, 14), 40);
     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;
     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 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 = {
     const containerStyles: CSSProperties = {
       width: `${width}px`,
       width: `${width}px`,
       height: `${height}px`,
       height: `${height}px`,
       display: 'flex',
       display: 'flex',
-      flexDirection: 'column',
-      justifyContent: 'flex-end',
     };
     };
 
 
     const barStyles: CSSProperties = {
     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 (
     return (
       <div style={containerStyles}>
       <div style={containerStyles}>
         <div className="bar-gauge__value" style={valueStyles}>
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
         </div>
         <div style={barStyles} />
         <div style={barStyles} />
       </div>
       </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 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 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 = {
     const containerStyles: CSSProperties = {
       width: `${width}px`,
       width: `${width}px`,
       height: `${height}px`,
       height: `${height}px`,
       display: 'flex',
       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[] = [];
     const cells: JSX.Element[] = [];
 
 
     for (let i = 0; i < cellCount; i++) {
     for (let i = 0; i < cellCount; i++) {
       const currentValue = (valueRange / cellCount) * i;
       const currentValue = (valueRange / cellCount) * i;
       const cellColor = this.getCellColor(currentValue);
       const cellColor = this.getCellColor(currentValue);
       const cellStyles: CSSProperties = {
       const cellStyles: CSSProperties = {
-        width: `${cellWidth}px`,
-        backgroundColor: cellColor,
-        marginRight: '4px',
-        height: `${height}px`,
         borderRadius: '2px',
         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} />);
       cells.push(<div style={cellStyles} />);
     }
     }
 
 
@@ -210,30 +285,22 @@ export class BarGauge extends PureComponent<Props> {
       <div style={containerStyles}>
       <div style={containerStyles}>
         {cells}
         {cells}
         <div className="bar-gauge__value" style={valueStyles}>
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
         </div>
       </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 {
 interface BarColors {
   value: string;
   value: string;
-  bar: string;
+  background: string;
+  border: string;
+}
+
+interface CellColors {
+  background: string;
+  backgroundShade?: string;
   border: 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 {
     Object {
       "alignItems": "center",
       "alignItems": "center",
       "display": "flex",
       "display": "flex",
-      "flexDirection": "row",
+      "flexDirection": "row-reverse",
       "height": "300px",
       "height": "300px",
+      "justifyContent": "flex-end",
       "width": "300px",
       "width": "300px",
     }
     }
   }
   }
 >
 >
   <div
   <div
+    className="bar-gauge__value"
     style={
     style={
       Object {
       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
   <div
     style={
     style={
       Object {
       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",
         "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>
 </div>
 `;
 `;

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

@@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
 
 
 interface Props {
 interface Props {
   onConfirm(): void;
   onConfirm(): void;
+  disabled?: boolean;
 }
 }
 
 
 interface State {
 interface State {
@@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
   };
   };
 
 
   render() {
   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 (
     return (
       <span className="delete-button-container">
       <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" />
           <i className="fa fa-remove" />
         </a>
         </a>
         <span className="confirm-delete-container">
         <span className="confirm-delete-container">
-          <span className={'confirm-delete ' + showConfirm}>
+          <span className={`confirm-delete ${showConfirmClass}`}>
             <a className="btn btn-small" onClick={this.onClickCancel}>
             <a className="btn btn-small" onClick={this.onClickCancel}>
               Cancel
               Cancel
             </a>
             </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 { shallow } from 'enzyme';
 
 
 import { Gauge, Props } from './Gauge';
 import { Gauge, Props } from './Gauge';
-import { ValueMapping, MappingType } from '../../types';
 import { getTheme } from '../../themes';
 import { getTheme } from '../../themes';
 
 
 jest.mock('jquery', () => ({
 jest.mock('jquery', () => ({
@@ -12,19 +11,16 @@ jest.mock('jquery', () => ({
 const setup = (propOverrides?: object) => {
 const setup = (propOverrides?: object) => {
   const props: Props = {
   const props: Props = {
     maxValue: 100,
     maxValue: 100,
-    valueMappings: [],
     minValue: 0,
     minValue: 0,
-    prefix: '',
     showThresholdMarkers: true,
     showThresholdMarkers: true,
     showThresholdLabels: false,
     showThresholdLabels: false,
-    suffix: '',
     thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
     thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
-    unit: 'none',
-    stat: 'avg',
     height: 300,
     height: 300,
     width: 300,
     width: 300,
-    value: 25,
-    decimals: 0,
+    value: {
+      text: '25',
+      numeric: 25,
+    },
     theme: getTheme(),
     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', () => {
 describe('Get thresholds formatted', () => {
   it('should return first thresholds color for min and max', () => {
   it('should return first thresholds color for min and max', () => {
     const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
     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 React, { PureComponent } from 'react';
 import $ from 'jquery';
 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';
 import { Themeable } from '../../index';
-
-type GaugeValue = string | number | null;
+import { DisplayValue } from '../../utils/displayValue';
 
 
 export interface Props extends Themeable {
 export interface Props extends Themeable {
-  decimals?: number | null;
   height: number;
   height: number;
-  valueMappings: ValueMapping[];
   maxValue: number;
   maxValue: number;
   minValue: number;
   minValue: number;
-  prefix: string;
   thresholds: Threshold[];
   thresholds: Threshold[];
   showThresholdMarkers: boolean;
   showThresholdMarkers: boolean;
   showThresholdLabels: boolean;
   showThresholdLabels: boolean;
-  stat: string;
-  suffix: string;
-  unit: string;
   width: number;
   width: number;
-  value: number;
+  value: DisplayValue;
 }
 }
 
 
 const FONT_SCALE = 1;
 const FONT_SCALE = 1;
@@ -32,15 +24,10 @@ export class Gauge extends PureComponent<Props> {
 
 
   static defaultProps: Partial<Props> = {
   static defaultProps: Partial<Props> = {
     maxValue: 100,
     maxValue: 100,
-    valueMappings: [],
     minValue: 0,
     minValue: 0,
-    prefix: '',
     showThresholdMarkers: true,
     showThresholdMarkers: true,
     showThresholdLabels: false,
     showThresholdLabels: false,
-    suffix: '',
     thresholds: [],
     thresholds: [],
-    unit: 'none',
-    stat: 'avg',
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
@@ -51,39 +38,6 @@ export class Gauge extends PureComponent<Props> {
     this.draw();
     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() {
   getFormattedThresholds() {
     const { maxValue, minValue, thresholds, theme } = this.props;
     const { maxValue, minValue, thresholds, theme } = this.props;
 
 
@@ -112,15 +66,13 @@ export class Gauge extends PureComponent<Props> {
   draw() {
   draw() {
     const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props;
     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 dimension = Math.min(width, height * 1.3);
     const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3;
     const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3;
 
 
     const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
     const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
     const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
     const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
     const thresholdMarkersWidth = gaugeWidth / 5;
     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 thresholdLabelFontSize = fontSize / 2.5;
 
 
     const options: any = {
     const options: any = {
@@ -149,9 +101,9 @@ export class Gauge extends PureComponent<Props> {
             width: thresholdMarkersWidth,
             width: thresholdMarkersWidth,
           },
           },
           value: {
           value: {
-            color: this.getFontColor(value),
+            color: value.color,
             formatter: () => {
             formatter: () => {
-              return formattedValue;
+              return value.text;
             },
             },
             font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
             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 {
     try {
       $.plot(this.canvasElement, [plotSeries], options);
       $.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 renderer from 'react-test-renderer';
 import { shallow } from 'enzyme';
 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 TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
 const testBlurValidation: ValidationEvents = {
 const testBlurValidation: ValidationEvents = {
   [EventsWithValidation.onBlur]: [
   [EventsWithValidation.onBlur]: [
     {
     {
       rule: (value: string) => {
       rule: (value: string) => {
-        if (!value || value.length < 3) {
-          return true;
-        }
-        return false;
+        return !value || value.length < 3;
       },
       },
       errorMessage: TEST_ERROR_MESSAGE,
       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 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 {
 export enum InputStatus {
   Invalid = 'invalid',
   Invalid = 'invalid',
   Valid = 'valid',
   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> {
 interface Props extends React.HTMLProps<HTMLInputElement> {
   validationEvents?: ValidationEvents;
   validationEvents?: ValidationEvents;
   hideErrorMessage?: boolean;
   hideErrorMessage?: boolean;
@@ -28,7 +15,7 @@ interface Props extends React.HTMLProps<HTMLInputElement> {
   // Override event props and append status as argument
   // Override event props and append status as argument
   onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
   onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
   onFocus?: (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> {
 export class Input extends PureComponent<Props> {
@@ -49,24 +36,24 @@ export class Input extends PureComponent<Props> {
   }
   }
 
 
   validatorAsync = (validationRules: ValidationRule[]) => {
   validatorAsync = (validationRules: ValidationRule[]) => {
-    return evt => {
+    return (evt: ChangeEvent<HTMLInputElement>) => {
       const errors = validate(evt.target.value, validationRules);
       const errors = validate(evt.target.value, validationRules);
       this.setState(prevState => {
       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 };
     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
           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]);
             await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
           }
           }
           if (restProps[eventName]) {
           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: '',
     key: '',
     onClick: jest.fn(),
     onClick: jest.fn(),
     onMouseOver: jest.fn(),
     onMouseOver: jest.fn(),
+    onMouseMove: jest.fn(),
     tabIndex: 1,
     tabIndex: 1,
   },
   },
   label: 'Option label',
   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
 <div
   id=""
   id=""
   onClick={[MockFunction]}
   onClick={[MockFunction]}
+  onMouseMove={[MockFunction]}
   onMouseOver={[MockFunction]}
   onMouseOver={[MockFunction]}
   tabIndex={1}
   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();
       const suffix = (rowId + 1).toString();
       return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
       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,
   CellMeasurerCache,
   CellMeasurer,
   CellMeasurer,
   GridCellProps,
   GridCellProps,
+  Index,
 } from 'react-virtualized';
 } from 'react-virtualized';
 import { Themeable } from '../../types/theme';
 import { Themeable } from '../../types/theme';
 
 
@@ -26,6 +27,7 @@ import { stringToJsRegex } from '../../utils/index';
 export interface Props extends Themeable {
 export interface Props extends Themeable {
   data: TableData;
   data: TableData;
 
 
+  minColumnWidth: number;
   showHeader: boolean;
   showHeader: boolean;
   fixedHeader: boolean;
   fixedHeader: boolean;
   fixedColumns: number;
   fixedColumns: number;
@@ -46,6 +48,7 @@ interface State {
 
 
 interface ColumnRenderInfo {
 interface ColumnRenderInfo {
   header: string;
   header: string;
+  width: number;
   builder: TableCellBuilder;
   builder: TableCellBuilder;
 }
 }
 
 
@@ -64,6 +67,7 @@ export class Table extends Component<Props, State> {
     fixedHeader: true,
     fixedHeader: true,
     fixedColumns: 0,
     fixedColumns: 0,
     rotate: false,
     rotate: false,
+    minColumnWidth: 150,
   };
   };
 
 
   constructor(props: Props) {
   constructor(props: Props) {
@@ -76,7 +80,7 @@ export class Table extends Component<Props, State> {
     this.renderer = this.initColumns(props);
     this.renderer = this.initColumns(props);
     this.measurer = new CellMeasurerCache({
     this.measurer = new CellMeasurerCache({
       defaultHeight: 30,
       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 */
   /** Given the configuration, setup how each column gets rendered */
   initColumns(props: Props): ColumnRenderInfo[] {
   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) => {
     return data.columns.map((col, index) => {
       let title = col.text;
       let title = col.text;
@@ -131,6 +136,7 @@ export class Table extends Component<Props, State> {
 
 
       return {
       return {
         header: title,
         header: title,
+        width: columnWidth,
         builder: getCellBuilder(col, style, this.props),
         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() {
   render() {
     const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props;
     const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props;
     const { data } = this.state;
     const { data } = this.state;
@@ -269,7 +279,7 @@ export class Table extends Component<Props, State> {
         rowCount={rowCount}
         rowCount={rowCount}
         overscanColumnCount={8}
         overscanColumnCount={8}
         overscanRowCount={8}
         overscanRowCount={8}
-        columnWidth={this.measurer.columnWidth}
+        columnWidth={this.getColumnWidth}
         deferredMeasurementCache={this.measurer}
         deferredMeasurementCache={this.measurer}
         cellRenderer={this.cellRenderer}
         cellRenderer={this.cellRenderer}
         rowHeight={this.measurer.rowHeight}
         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 React, { ChangeEvent } from 'react';
 import { mount } from 'enzyme';
 import { mount } from 'enzyme';
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
+import { colors } from '../../utils';
 
 
 const setup = (propOverrides?: Partial<Props>) => {
 const setup = (propOverrides?: Partial<Props>) => {
   const props: Props = {
   const props: Props = {
@@ -31,7 +32,7 @@ describe('Initialization', () => {
   it('should add a base threshold if missing', () => {
   it('should add a base threshold if missing', () => {
     const { instance } = setup();
     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);
     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', () => {
   it('should add threshold', () => {
@@ -50,41 +51,41 @@ describe('Add threshold', () => {
     instance.onAddThreshold(1);
     instance.onAddThreshold(1);
 
 
     expect(instance.state.thresholds).toEqual([
     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', () => {
   it('should add another threshold above a first', () => {
     const { instance } = setup({
     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);
     instance.onAddThreshold(2);
 
 
     expect(instance.state.thresholds).toEqual([
     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', () => {
   it('should add another threshold between first and second index', () => {
     const { instance } = setup({
     const { instance } = setup({
       thresholds: [
       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);
     instance.onAddThreshold(2);
 
 
     expect(instance.state.thresholds).toEqual([
     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', () => {
 describe('on blur threshold value', () => {
-  it.only('should resort rows and update indexes', () => {
+  it('should resort rows and update indexes', () => {
     const { instance } = setup();
     const { instance } = setup();
     const thresholds = [
     const thresholds = [
       { index: 0, value: -Infinity, color: '#7EB26D' },
       { 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 { ColorPicker } from '..';
 import { PanelOptionsGroup } from '..';
 import { PanelOptionsGroup } from '..';
 import { colors } from '../../utils';
 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 {
 export interface Props {
   thresholds: Threshold[];
   thresholds: Threshold[];
@@ -166,7 +166,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
         <div className="thresholds-row-input-inner-color">
         <div className="thresholds-row-input-inner-color">
           {threshold.color && (
           {threshold.color && (
             <div className="thresholds-row-input-inner-color-colorpicker">
             <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>
           )}
           )}
         </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
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 
 exports[`Render should render with base threshold 1`] = `
 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 { Switch } from './Switch/Switch';
 export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
 export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
 export { UnitPicker } from './UnitPicker/UnitPicker';
 export { UnitPicker } from './UnitPicker/UnitPicker';
+export { Input, InputStatus } from './Input/Input';
 
 
 // Visualizations
 // Visualizations
 export { Gauge } from './Gauge/Gauge';
 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-width: ${theme.border.width.sm} !default;
 
 
 $border-radius: ${theme.border.radius.md} !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;
 $border-radius-sm: ${theme.border.radius.sm} !default;
 
 
 // Page
 // Page
@@ -191,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
 $btn-padding-x-xl: 21px !default;
 $btn-padding-x-xl: 21px !default;
 $btn-padding-y-xl: 11px !default;
 $btn-padding-y-xl: 11px !default;
 
 
-
 $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
 $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
 
 
 // sidemenu
 // sidemenu

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

@@ -51,27 +51,13 @@ export enum NullValueMode {
 export type TimeSeriesVMs = TimeSeriesVM[];
 export type TimeSeriesVMs = TimeSeriesVM[];
 
 
 export interface Column {
 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;
   unit?: string;
 }
 }
 
 
 export interface TableData {
 export interface TableData {
   columns: Column[];
   columns: Column[];
   rows: any[];
   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 './datasource';
 export * from './theme';
 export * from './theme';
 export * from './threshold';
 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 { ComponentClass } from 'react';
-import { TimeSeries, LoadingState, TableData } from './data';
+import { LoadingState, TableData } from './data';
 import { TimeRange } from './time';
 import { TimeRange } from './time';
 import { ScopedVars } from './datasource';
 import { ScopedVars } from './datasource';
 
 
 export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
 export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
 
 
 export interface PanelProps<T = any> {
 export interface PanelProps<T = any> {
-  panelData: PanelData;
+  data?: TableData[];
   timeRange: TimeRange;
   timeRange: TimeRange;
   loading: LoadingState;
   loading: LoadingState;
   options: T;
   options: T;
@@ -16,23 +16,26 @@ export interface PanelProps<T = any> {
   replaceVariables: InterpolateFunction;
   replaceVariables: InterpolateFunction;
 }
 }
 
 
-export interface PanelData {
-  timeSeries?: TimeSeries[];
-  tableData?: TableData;
-}
-
 export interface PanelEditorProps<T = any> {
 export interface PanelEditorProps<T = any> {
   options: T;
   options: T;
   onOptionsChange: (options: T) => void;
   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> {
 export class ReactPanelPlugin<TOptions = any> {
   panel: ComponentClass<PanelProps<TOptions>>;
   panel: ComponentClass<PanelProps<TOptions>>;
   editor?: ComponentClass<PanelEditorProps<TOptions>>;
   editor?: ComponentClass<PanelEditorProps<TOptions>>;
   defaults?: TOptions;
   defaults?: TOptions;
-  preserveOptions?: PreservePanelOptionsHandler<TOptions>;
+
+  panelTypeChangedHook?: PanelTypeChangedHook<TOptions>;
 
 
   constructor(panel: ComponentClass<PanelProps<TOptions>>) {
   constructor(panel: ComponentClass<PanelProps<TOptions>>) {
     this.panel = panel;
     this.panel = panel;
@@ -46,8 +49,12 @@ export class ReactPanelPlugin<TOptions = any> {
     this.defaults = defaults;
     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[];
   includes: PluginInclude[];
 
 
   // Datasource-specific
   // Datasource-specific
+  builtIn?: boolean;
   metrics?: boolean;
   metrics?: boolean;
   tables?: boolean;
   tables?: boolean;
   logs?: 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`] = `
 exports[`processTableData basic processing should generate a header and fix widths 1`] = `
 Object {
 Object {
-  "columnMap": Object {},
   "columns": Array [
   "columns": Array [
     Object {
     Object {
       "text": "Column 1",
       "text": "Column 1",
@@ -31,13 +30,11 @@ Object {
       null,
       null,
     ],
     ],
   ],
   ],
-  "type": "table",
 }
 }
 `;
 `;
 
 
 exports[`processTableData basic processing should read header and two rows 1`] = `
 exports[`processTableData basic processing should read header and two rows 1`] = `
 Object {
 Object {
-  "columnMap": Object {},
   "columns": Array [
   "columns": Array [
     Object {
     Object {
       "text": "a",
       "text": "a",
@@ -61,6 +58,5 @@ Object {
       6,
       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 './processTimeSeries';
-export * from './singlestat';
+export * from './processTableData';
 export * from './valueFormats/valueFormats';
 export * from './valueFormats/valueFormats';
 export * from './colors';
 export * from './colors';
 export * from './namedColorsPalette';
 export * from './namedColorsPalette';
 export * from './thresholds';
 export * from './thresholds';
 export * from './string';
 export * from './string';
+export * from './displayValue';
 export * from './deprecationWarning';
 export * from './deprecationWarning';
 export { getMappedValue } from './valueMappings';
 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('processTableData', () => {
   describe('basic processing', () => {
   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';
 import Papa, { ParseError, ParseMeta } from 'papaparse';
 
 
 // Types
 // Types
-import { TableData, Column } from '../types';
+import { TableData, Column, TimeSeries } from '../types';
 
 
 // Subset of all parse options
 // Subset of all parse options
 export interface TableParseOptions {
 export interface TableParseOptions {
@@ -70,8 +70,6 @@ export function matchRowSizes(table: TableData): TableData {
   return {
   return {
     columns,
     columns,
     rows: fixedRows,
     rows: fixedRows,
-    type: table.type,
-    columnMap: table.columnMap,
   };
   };
 }
 }
 
 
@@ -118,8 +116,6 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
     return {
     return {
       columns: [],
       columns: [],
       rows: [],
       rows: [],
-      type: 'table',
-      columnMap: {},
     };
     };
   }
   }
 
 
@@ -130,11 +126,48 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
   return matchRowSizes({
   return matchRowSizes({
     columns: makeColumns(header),
     columns: makeColumns(header),
     rows: results.data,
     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 {
 export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
   if (isNumber(sortIndex)) {
   if (isNumber(sortIndex)) {
     const copy = {
     const copy = {

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

@@ -4,17 +4,36 @@ import isNumber from 'lodash/isNumber';
 import { colors } from './colors';
 import { colors } from './colors';
 
 
 // Types
 // Types
-import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
+import { TimeSeriesVMs, NullValueMode, TimeSeriesValue, TableData } from '../types';
 
 
 interface Options {
 interface Options {
-  timeSeries: TimeSeries[];
+  data: TableData[];
+  xColumn?: number; // Time (or null to guess)
+  yColumn?: number; // Value (or null to guess)
   nullValueMode: NullValueMode;
   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 colorIndex = index % colors.length;
-    const label = item.target;
+    const label = item.columns[yColumn].text;
     const result = [];
     const result = [];
 
 
     // stat defaults
     // stat defaults
@@ -42,9 +61,9 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
     let previousValue = 0;
     let previousValue = 0;
     let previousDeltaUp = true;
     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') {
       if (typeof currentTime !== 'number') {
         continue;
         continue;
@@ -95,7 +114,7 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
           if (previousValue > currentValue) {
           if (previousValue > currentValue) {
             // counter reset
             // counter reset
             previousDeltaUp = false;
             previousDeltaUp = false;
-            if (i === item.datapoints.length - 1) {
+            if (i === item.rows.length - 1) {
               // reset on last
               // reset on last
               delta += currentValue;
               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")
 	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/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 
 
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
@@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) {
 			So(sc.resp.Code, ShouldEqual, 400)
 			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) {
 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)
 		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
 	reqGrafanaAdmin := middleware.ReqGrafanaAdmin
 	reqEditorRole := middleware.ReqEditorRole
 	reqEditorRole := middleware.ReqEditorRole
 	reqOrgAdmin := middleware.ReqOrgAdmin
 	reqOrgAdmin := middleware.ReqOrgAdmin
+	reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin)
 	redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
 	redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
 	redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
 	redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
 	quota := middleware.Quota(hs.QuotaService)
 	quota := middleware.Quota(hs.QuotaService)
@@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/org/users", reqOrgAdmin, hs.Index)
 	r.Get("/org/users", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/new", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/new", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/invite", 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("/org/apikeys/", reqOrgAdmin, hs.Index)
 	r.Get("/dashboard/import/", reqSignedIn, hs.Index)
 	r.Get("/dashboard/import/", reqSignedIn, hs.Index)
 	r.Get("/configuration", reqGrafanaAdmin, hs.Index)
 	r.Get("/configuration", reqGrafanaAdmin, hs.Index)
@@ -133,6 +134,9 @@ func (hs *HTTPServer) registerRoutes() {
 
 
 			userRoute.Get("/preferences", Wrap(GetUserPreferences))
 			userRoute.Get("/preferences", Wrap(GetUserPreferences))
 			userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences))
 			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)
 		// users (admin permission required)
@@ -150,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() {
 
 
 		// team (admin permission required)
 		// team (admin permission required)
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
 		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.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
 		// team without requirement of user to be org admin
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
 			teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
 			teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
-			teamsRoute.Get("/search", Wrap(SearchTeams))
+			teamsRoute.Get("/search", Wrap(hs.SearchTeams))
 		})
 		})
 
 
 		// org information available to all users.
 		// org information available to all users.
@@ -262,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() {
 		apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
 		apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
 			folderRoute.Get("/", Wrap(GetFolders))
 			folderRoute.Get("/", Wrap(GetFolders))
 			folderRoute.Get("/id/:id", Wrap(GetFolderByID))
 			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) {
 			folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
 				folderUidRoute.Get("/", Wrap(GetFolderByUID))
 				folderUidRoute.Get("/", Wrap(GetFolderByUID))
@@ -375,6 +380,10 @@ func (hs *HTTPServer) registerRoutes() {
 		adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
 		adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
 		adminRoute.Get("/stats", AdminGetStats)
 		adminRoute.Get("/stats", AdminGetStats)
 		adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts))
 		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)
 	}, reqGrafanaAdmin)
 
 
 	// rendering
 	// 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, 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...)
 			r.Route(url, route.Method, handlers...)
 			log.Debug("Plugins: Adding proxy route %s", url)
 			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) {
 	return func(c *m.ReqContext) {
 		path := c.Params("*")
 		path := c.Params("*")
 
 
-		proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID)
+		proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg)
 		proxy.Transport = pluginProxyTransport
 		proxy.Transport = pluginProxyTransport
 		proxy.ServeHTTP(c.Resp, c.Req.Request)
 		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/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"gopkg.in/macaron.v1"
 	"gopkg.in/macaron.v1"
 
 
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
@@ -94,13 +95,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
 }
 }
 
 
 type scenarioContext struct {
 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() {
 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()
 	dash := cmd.GetDashboardModel()
 
 
-	if dash.Id == 0 && dash.Uid == "" {
+	newDashboard := dash.Id == 0 && dash.Uid == ""
+	if newDashboard {
 		limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
 		limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
 		if err != nil {
 		if err != nil {
 			return Error(500, "failed to get quota", err)
 			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)
 		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)
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	return JSON(200, util.DynMap{
 	return JSON(200, util.DynMap{
 		"status":  "success",
 		"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{
 		hs := HTTPServer{
 			Bus: bus.GetBus(),
 			Bus: bus.GetBus(),
+			Cfg: setting.NewCfg(),
 		}
 		}
 
 
 		sc := setupScenarioContext(url)
 		sc := setupScenarioContext(url)
@@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
 		hs := HTTPServer{
 		hs := HTTPServer{
+			Cfg: setting.NewCfg(),
 			Bus: bus.GetBus(),
 			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
 	// macaron does not include trailing slashes when resolving a wildcard path
 	proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
 	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()
 	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))
 	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)
 	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
 	err := s.CreateFolder(&cmd)
 	err := s.CreateFolder(&cmd)
 	if err != nil {
 	if err != nil {
 		return toFolderError(err)
 		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)
 	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
 	return JSON(200, toFolderDto(g, cmd.Result))
 	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"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/dashboards"
 	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/setting"
 
 
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
@@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
 	Convey(desc+" "+url, func() {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
+		hs := HTTPServer{
+			Bus: bus.GetBus(),
+			Cfg: setting.NewCfg(),
+		}
+
 		sc := setupScenarioContext(url)
 		sc := setupScenarioContext(url)
 		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
 
 
-			return CreateFolder(c, cmd)
+			return hs.CreateFolder(c, cmd)
 		})
 		})
 
 
 		origNewFolderService := dashboards.NewFolderService
 		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,
 		"externalUserMngLinkUrl":     setting.ExternalUserMngLinkUrl,
 		"externalUserMngLinkName":    setting.ExternalUserMngLinkName,
 		"externalUserMngLinkName":    setting.ExternalUserMngLinkName,
 		"viewersCanEdit":             setting.ViewersCanEdit,
 		"viewersCanEdit":             setting.ViewersCanEdit,
-		"editorsCanOwn":              hs.Cfg.EditorsCanOwn,
+		"editorsCanAdmin":            hs.Cfg.EditorsCanAdmin,
 		"disableSanitizeHtml":        hs.Cfg.DisableSanitizeHtml,
 		"disableSanitizeHtml":        hs.Cfg.DisableSanitizeHtml,
 		"buildInfo": map[string]interface{}{
 		"buildInfo": map[string]interface{}{
 			"version":       setting.BuildVersion,
 			"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{
 	data.NavTree = append(data.NavTree, &dtos.NavLink{
 		Text:         "Help",
 		Text:         "Help",
 		SubTitle:     fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),
 		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
 	proxyPath string
 	route     *plugins.AppPluginRoute
 	route     *plugins.AppPluginRoute
 	plugin    *plugins.DataSourcePlugin
 	plugin    *plugins.DataSourcePlugin
+	cfg       *setting.Cfg
 }
 }
 
 
 type httpClient interface {
 type httpClient interface {
 	Do(req *http.Request) (*http.Response, error)
 	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)
 	targetURL, _ := url.Parse(ds.Url)
 
 
 	return &DataSourceProxy{
 	return &DataSourceProxy{
@@ -49,6 +50,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
 		ctx:       ctx,
 		ctx:       ctx,
 		proxyPath: proxyPath,
 		proxyPath: proxyPath,
 		targetUrl: targetURL,
 		targetUrl: targetURL,
+		cfg:       cfg,
 	}
 	}
 }
 }
 
 
@@ -170,6 +172,10 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
 			req.Header.Add("Authorization", dsAuth)
 			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
 		// clear cookie header, except for whitelisted cookies
 		var keptCookies []*http.Cookie
 		var keptCookies []*http.Cookie
 		if proxy.ds.JsonData != nil {
 		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() {
 			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]
 				proxy.route = plugin.Routes[0]
 				ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 				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() {
 			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]
 				proxy.route = plugin.Routes[3]
 				ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 				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("Validating request", func() {
 				Convey("plugin route with valid role", 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()
 					err := proxy.validateRequest()
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 				})
 				})
 
 
 				Convey("plugin route with admin role and user is editor", func() {
 				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()
 					err := proxy.validateRequest()
 					So(err, ShouldNotBeNil)
 					So(err, ShouldNotBeNil)
 				})
 				})
 
 
 				Convey("plugin route with admin role and user is admin", func() {
 				Convey("plugin route with admin role and user is admin", func() {
 					ctx.SignedInUser.OrgRole = m.ROLE_ADMIN
 					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()
 					err := proxy.validateRequest()
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 				})
 				})
@@ -186,7 +186,7 @@ func TestDSRouteRule(t *testing.T) {
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 
 
 					client = newFakeHTTPClient(json)
 					client = newFakeHTTPClient(json)
-					proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
+					proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
 					proxy1.route = plugin.Routes[0]
 					proxy1.route = plugin.Routes[0]
 					ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds)
 					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)
 						req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
 						client = newFakeHTTPClient(json2)
 						client = newFakeHTTPClient(json2)
-						proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2")
+						proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", &setting.Cfg{})
 						proxy2.route = plugin.Routes[1]
 						proxy2.route = plugin.Routes[1]
 						ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds)
 						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)
 							req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
 
 
 							client = newFakeHTTPClient([]byte{})
 							client = newFakeHTTPClient([]byte{})
-							proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
+							proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
 							proxy3.route = plugin.Routes[0]
 							proxy3.route = plugin.Routes[0]
 							ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds)
 							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}
 			ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE}
 			ctx := &m.ReqContext{}
 			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)
 			req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
 
 
@@ -261,7 +261,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 			}
 
 
 			ctx := &m.ReqContext{}
 			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)
 			req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
@@ -291,7 +291,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 			}
 
 
 			ctx := &m.ReqContext{}
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -317,7 +317,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 			}
 
 
 			ctx := &m.ReqContext{}
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -347,7 +347,7 @@ func TestDSRouteRule(t *testing.T) {
 			}
 			}
 
 
 			ctx := &m.ReqContext{}
 			ctx := &m.ReqContext{}
-			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
 
 
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			requestURL, _ := url.Parse("http://grafana.com/sub")
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
 			req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -369,7 +369,7 @@ func TestDSRouteRule(t *testing.T) {
 				Url:  "http://host/root/",
 				Url:  "http://host/root/",
 			}
 			}
 			ctx := &m.ReqContext{}
 			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, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
 			req.Header.Add("Origin", "grafana.com")
 			req.Header.Add("Origin", "grafana.com")
 			req.Header.Add("Referer", "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")
 				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 {
 type httpClientStub struct {
 	fakeBody []byte
 	fakeBody []byte
 }
 }

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

@@ -2,6 +2,7 @@ package pluginproxy
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"github.com/grafana/grafana/pkg/setting"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"net/http/httputil"
 	"net/http/httputil"
@@ -37,7 +38,7 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http.
 	return result, err
 	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)
 	targetURL, _ := url.Parse(route.Url)
 
 
 	director := func(req *http.Request) {
 	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))
 		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 {
 		if len(route.Headers) > 0 {
 			headers, err := getHeaders(route, ctx.OrgId, appID)
 			headers, err := getHeaders(route, ctx.OrgId, appID)
 			if err != nil {
 			if err != nil {

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

@@ -1,6 +1,7 @@
 package pluginproxy
 package pluginproxy
 
 
 import (
 import (
+	"net/http"
 	"testing"
 	"testing"
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"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/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/teamguardian"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 // POST /api/teams
 // 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
 	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 {
 		if err == m.ErrTeamNameTaken {
 			return Error(409, "Team name taken", err)
 			return Error(409, "Team name taken", err)
 		}
 		}
 		return Error(500, "Failed to create Team", 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{
 	return JSON(200, &util.DynMap{
 		"teamId":  cmd.Result.Id,
 		"teamId":  cmd.Result.Id,
 		"message": "Team created",
 		"message": "Team created",
@@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
 }
 }
 
 
 // PUT /api/teams/:teamId
 // 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.OrgId = c.OrgId
 	cmd.Id = c.ParamsInt64(":teamId")
 	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 {
 		if err == m.ErrTeamNameTaken {
 			return Error(400, "Team name taken", err)
 			return Error(400, "Team name taken", err)
 		}
 		}
@@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
 }
 }
 
 
 // DELETE /api/teams/:teamId
 // 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 {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Failed to delete Team. ID not found", nil)
 			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")
 	return Success("Team deleted")
 }
 }
 
 
 // GET /api/teams/search
 // GET /api/teams/search
-func SearchTeams(c *m.ReqContext) Response {
+func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response {
 	perPage := c.QueryInt("perpage")
 	perPage := c.QueryInt("perpage")
 	if perPage <= 0 {
 	if perPage <= 0 {
 		perPage = 1000
 		perPage = 1000
@@ -59,12 +91,18 @@ func SearchTeams(c *m.ReqContext) Response {
 		page = 1
 		page = 1
 	}
 	}
 
 
+	var userIdFilter int64
+	if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN {
+		userIdFilter = c.SignedInUser.UserId
+	}
+
 	query := m.SearchTeamsQuery{
 	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 {
 	if err := bus.Dispatch(&query); err != nil {
@@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response {
 }
 }
 
 
 // GET /api/teams/:teamId/preferences
 // 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
 // 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/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	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/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
 )
 )
@@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response {
 }
 }
 
 
 // POST /api/teams/:teamId/members
 // 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.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 {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Team not found", nil)
 			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
 // 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 {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Team not found", nil)
 			return Error(404, "Team not found", nil)
 		}
 		}

+ 8 - 2
pkg/api/team_test.go

@@ -3,6 +3,8 @@ package api
 import (
 import (
 	"testing"
 	"testing"
 
 
+	"github.com/grafana/grafana/pkg/setting"
+
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/models"
@@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) {
 			TotalCount: 2,
 			TotalCount: 2,
 		}
 		}
 
 
+		hs := &HTTPServer{
+			Cfg: setting.NewCfg(),
+		}
+
 		Convey("When searching with no parameters", func() {
 		Convey("When searching with no parameters", func() {
 			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
 			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
 				var sentLimit int
 				var sentLimit int
@@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) {
 					return nil
 					return nil
 				})
 				})
 
 
-				sc.handlerFunc = SearchTeams
+				sc.handlerFunc = hs.SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
 				So(sentLimit, ShouldEqual, 1000)
 				So(sentLimit, ShouldEqual, 1000)
@@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) {
 					return nil
 					return nil
 				})
 				})
 
 
-				sc.handlerFunc = SearchTeams
+				sc.handlerFunc = hs.SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
 
 
 				So(sentLimit, ShouldEqual, 10)
 				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
 	// self registering services
 	_ "github.com/grafana/grafana/pkg/extensions"
 	_ "github.com/grafana/grafana/pkg/extensions"
 	_ "github.com/grafana/grafana/pkg/infra/metrics"
 	_ "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/serverlock"
 	_ "github.com/grafana/grafana/pkg/infra/tracing"
 	_ "github.com/grafana/grafana/pkg/infra/tracing"
 	_ "github.com/grafana/grafana/pkg/infra/usagestats"
 	_ "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"
 	msession "github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	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/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
@@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 				return &m.UserToken{
 					UserId:        12,
 					UserId:        12,
 					UnhashedToken: unhashedToken,
 					UnhashedToken: unhashedToken,
@@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 				return &m.UserToken{
 					UserId:        12,
 					UserId:        12,
 					UnhashedToken: "",
 					UnhashedToken: "",
 				}, nil
 				}, 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"
 				userToken.UnhashedToken = "rotated"
 				return true, nil
 				return true, nil
 			}
 			}
@@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) {
 		middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
 		middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
 			sc.withTokenSessionCookie("token")
 			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
 				return nil, m.ErrUserTokenNotFound
 			}
 			}
 
 
@@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 		}))
 		}))
 
 
 		session.Init(&msession.Options{}, 0)
 		session.Init(&msession.Options{}, 0)
-		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
 		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		// mock out gc goroutine
 		session.StartSessionGC = func() {}
 		session.StartSessionGC = func() {}
@@ -595,7 +596,7 @@ type scenarioContext struct {
 	handlerFunc          handlerFunc
 	handlerFunc          handlerFunc
 	defaultHandler       macaron.Handler
 	defaultHandler       macaron.Handler
 	url                  string
 	url                  string
-	userAuthTokenService *fakeUserAuthTokenService
+	userAuthTokenService *auth.FakeUserAuthTokenService
 
 
 	req *http.Request
 	req *http.Request
 }
 }
@@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() {
 
 
 type scenarioFunc func(c *scenarioContext)
 type scenarioFunc func(c *scenarioContext)
 type handlerFunc func(c *m.ReqContext)
 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
 				return nil
 			})
 			})
 
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 				return &m.UserToken{
 					UserId:        0,
 					UserId:        0,
 					UnhashedToken: "",
 					UnhashedToken: "",
@@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+			sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
 				return &m.UserToken{
 				return &m.UserToken{
 					UserId:        12,
 					UserId:        12,
 					UnhashedToken: "",
 					UnhashedToken: "",

+ 3 - 2
pkg/middleware/quota_test.go

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

+ 2 - 1
pkg/middleware/recovery_test.go

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

+ 19 - 14
pkg/models/team.go

@@ -7,9 +7,12 @@ import (
 
 
 // Typed errors
 // Typed errors
 var (
 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
 // Team model
@@ -59,22 +62,24 @@ type GetTeamsByUserQuery struct {
 }
 }
 
 
 type SearchTeamsQuery 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
 	Result SearchTeamQueryResult
 }
 }
 
 
 type TeamDTO struct {
 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 {
 type SearchTeamQueryResult struct {

+ 32 - 20
pkg/models/team_member.go

@@ -12,11 +12,12 @@ var (
 
 
 // TeamMember model
 // TeamMember model
 type TeamMember struct {
 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
 	Created time.Time
 	Updated time.Time
 	Updated time.Time
@@ -26,16 +27,26 @@ type TeamMember struct {
 // COMMANDS
 // COMMANDS
 
 
 type AddTeamMemberCommand struct {
 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 {
 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
 // Projections and DTOs
 
 
 type TeamMemberDTO struct {
 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
 package models
 
 
-import "errors"
+import (
+	"errors"
+)
 
 
 // Typed errors
 // Typed errors
 var (
 var (
@@ -23,11 +25,18 @@ type UserToken struct {
 	UnhashedToken string
 	UnhashedToken string
 }
 }
 
 
+type RevokeAuthTokenCmd struct {
+	AuthTokenId int64 `json:"authTokenId"`
+}
+
 // UserTokenService are used for generating and validating user tokens
 // UserTokenService are used for generating and validating user tokens
 type UserTokenService interface {
 type UserTokenService interface {
 	CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
 	CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
 	LookupToken(unhashedToken string) (*UserToken, error)
 	LookupToken(unhashedToken string) (*UserToken, error)
 	TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
 	TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
 	RevokeToken(token *UserToken) error
 	RevokeToken(token *UserToken) error
+	RevokeAllUserTokens(userId int64) error
 	ActiveTokenCount() (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
 		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 := simplejson.New()
 	bodyJSON.Set("message_type", messageType)
 	bodyJSON.Set("message_type", messageType)
 	bodyJSON.Set("entity_id", evalContext.Rule.Name)
 	bodyJSON.Set("entity_id", evalContext.Rule.Name)
+	bodyJSON.Set("entity_display_name", evalContext.GetNotificationTitle())
 	bodyJSON.Set("timestamp", time.Now().Unix())
 	bodyJSON.Set("timestamp", time.Now().Unix())
 	bodyJSON.Set("state_start_time", evalContext.StartTime.Unix())
 	bodyJSON.Set("state_start_time", evalContext.StartTime.Unix())
 	bodyJSON.Set("state_message", evalContext.Rule.Message)
 	bodyJSON.Set("state_message", evalContext.Rule.Message)
 	bodyJSON.Set("monitoring_tool", "Grafana v"+setting.BuildVersion)
 	bodyJSON.Set("monitoring_tool", "Grafana v"+setting.BuildVersion)
 	bodyJSON.Set("alert_url", ruleUrl)
 	bodyJSON.Set("alert_url", ruleUrl)
+	bodyJSON.Set("metrics", fields)
+
+	if evalContext.Error != nil {
+		bodyJSON.Set("error_message", evalContext.Error.Error())
+	}
 
 
 	if evalContext.ImagePublicUrl != "" {
 	if evalContext.ImagePublicUrl != "" {
 		bodyJSON.Set("image_url", 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
 	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 {
 func (s *UserAuthTokenService) createdAfterParam() int64 {
 	tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
 	tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
 	return getTime().Add(-tokenMaxLifetime).Unix()
 	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)
 				err = userAuthTokenService.RevokeToken(userToken)
 				So(err, ShouldEqual, models.ErrUserTokenNotFound)
 				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() {
 		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)
 	addUserAuthMigrations(mg)
 	addServerlockMigrations(mg)
 	addServerlockMigrations(mg)
 	addUserAuthTokenMigrations(mg)
 	addUserAuthTokenMigrations(mg)
+	addCacheMigration(mg)
 }
 }
 
 
 func addMigrationLogMigrations(mg *Migrator) {
 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{
 	mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
 		Name: "external", Type: DB_Bool, Nullable: true,
 		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", GetTeamsByUser)
 
 
 	bus.AddHandler("sql", AddTeamMember)
 	bus.AddHandler("sql", AddTeamMember)
+	bus.AddHandler("sql", UpdateTeamMember)
 	bus.AddHandler("sql", RemoveTeamMember)
 	bus.AddHandler("sql", RemoveTeamMember)
 	bus.AddHandler("sql", GetTeamMembers)
 	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 {
 func getTeamSelectSqlBase() string {
 	return `SELECT
 	return `SELECT
 		team.id as id,
 		team.id as id,
 		team.org_id,
 		team.org_id,
 		team.name as name,
 		team.name as name,
 		team.email as email,
 		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 `
 		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
 // DeleteTeam will delete a team, its member and any permissions connected to the team
 func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 	return inTransaction(func(sess *DBSession) 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
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
 		}
 		}
 
 
 		deletes := []string{
 		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 {
 	if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
 		return false, err
 		return false, err
 	} else if len(res) != 1 {
 	} else if len(res) != 1 {
-		return false, nil
+		return false, m.ErrTeamNotFound
 	}
 	}
 
 
 	return true, nil
 	return true, nil
@@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 	var sql bytes.Buffer
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 	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 = ?`)
 	sql.WriteString(` WHERE team.org_id = ?`)
 
 
 	params = append(params, query.OrgId)
 	params = append(params, query.OrgId)
@@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 			return m.ErrTeamMemberAlreadyAdded
 			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
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
 		}
 		}
 
 
 		entity := m.TeamMember{
 		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)
 		_, 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
 // RemoveTeamMember removes a member from a team
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 	return inTransaction(func(sess *DBSession) 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
 			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=?"
 		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
 // GetTeamMembers return a list of members for the specified team
 func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	query.Result = make([]*m.TeamMemberDTO, 0)
 	query.Result = make([]*m.TeamMemberDTO, 0)
@@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	if query.External {
 	if query.External {
 		sess.Where("team_member.external=?", dialect.BooleanStr(true))
 		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")
 	sess.Asc("user.login", "user.email")
 
 
 	err := sess.Find(&query.Result)
 	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)
 				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() {
 			Convey("Should be able to search for teams", func() {
 				query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
 				query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
 				err = SearchTeams(query)
 				err = SearchTeams(query)
@@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				So(len(q2.Result), ShouldEqual, 0)
 				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() {
 			Convey("Should be able to remove a group with users and permissions", func() {
 				groupId := group2.Result.Id
 				groupId := group2.Result.Id
 				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})
 				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
 	LoginMaxLifetimeDays         int
 	TokenRotationIntervalMinutes int
 	TokenRotationIntervalMinutes int
 
 
-	// User
-	EditorsCanOwn bool
+	// Dataproxy
+	SendUserHeader bool
+
+	// DistributedCache
+	RemoteCacheOptions *RemoteCacheOptions
+
+	EditorsCanAdmin bool
 }
 }
 
 
 type CommandLineArgs struct {
 type CommandLineArgs struct {
@@ -601,6 +606,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	dataproxy := iniFile.Section("dataproxy")
 	dataproxy := iniFile.Section("dataproxy")
 	DataProxyLogging = dataproxy.Key("logging").MustBool(false)
 	DataProxyLogging = dataproxy.Key("logging").MustBool(false)
 	DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
 	DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
+	cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false)
 
 
 	// read security settings
 	// read security settings
 	security := iniFile.Section("security")
 	security := iniFile.Section("security")
@@ -663,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
 	ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
 	ExternalUserMngInfo = users.Key("external_manage_info").String()
 	ExternalUserMngInfo = users.Key("external_manage_info").String()
 	ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
 	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
 	auth := iniFile.Section("auth")
 	auth := iniFile.Section("auth")
@@ -781,9 +787,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	enterprise := iniFile.Section("enterprise")
 	enterprise := iniFile.Section("enterprise")
 	cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
 	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
 	return nil
 }
 }
 
 
+type RemoteCacheOptions struct {
+	Name    string
+	ConnStr string
+}
+
 func (cfg *Cfg) readSessionConfig() {
 func (cfg *Cfg) readSessionConfig() {
 	sec := cfg.Raw.Section("session")
 	sec := cfg.Raw.Section("session")
 	SessionOptions = session.Options{}
 	SessionOptions = session.Options{}

Some files were not shown because too many files changed in this diff