Merge remote-tracking branch 'grafana/master' into all-data-as-table

* grafana/master: (57 commits)
  feature(explore/table): Add tooltips to explore table (#16007)
  Update changelog
  Add check for Env before log
  Update index.md
  chore: Cleaning up implicit anys in manage_dashboard.ts and manage_dashboard.test.ts progress: #14714
  chore: Cleaning up implicit anys in app.ts progress: #14714
  changelog: adds note about closing #15836
  changelog: adds note about closing #6359 and #15931
  add partial
  Add check so that header is not sent for anonymous users
  Update config docs
  Add custom header with grafana user and a config switch for it
  changelog: adds note about closing #10816
  use constants for cache type
  makes variables template prettier complient
  white space formating
  changelog: adds note about #15744
  updates old distcache names
  dont allow inifinite expiration
  return error if cache type is invalid
  ...
This commit is contained in:
ryan 2019-03-15 09:19:01 -07:00
commit ff3c25ad79
60 changed files with 2086 additions and 229 deletions

View File

@ -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
@ -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

View File

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

View File

@ -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.

View File

@ -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.

View File

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

View File

@ -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

View File

@ -105,6 +105,14 @@ region = us-west-2
You need to specify a namespace, metric, at least one stat, and at least one dimension. You need to specify a namespace, metric, at least one stat, and at least one dimension.
## Metric Math
You can now create new time series metrics by operating on top of Cloudwatch metrics using mathematical functions. Arithmetic operators, unary subtraction and other functions are supported to be applied on cloudwatch metrics. More details on the available functions can be found on [AWS Metric Math](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html)
As an example, if you want to apply arithmetic operator on a metric, you can do it by giving an alias(a unique string) to the raw metric as shown below. Then you can use this alias and apply arithmetic operator to it in the Expression field of created metric.
![](/img/docs/v60/cloudwatch_metric_math.png)
## Templated queries ## Templated queries
Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place. Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place.

View File

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

View File

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

View File

@ -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 />
@ -399,6 +411,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

View File

@ -163,7 +163,7 @@ Expected result from datasource.annotationQuery:
"title": "Cluster outage", "title": "Cluster outage",
"time": 1457075272576, "time": 1457075272576,
"text": "Joe causes brain split", "text": "Joe causes brain split",
"tags": "joe, cluster, failure" "tags": ["joe", "cluster", "failure"]
} }
] ]
``` ```

View File

@ -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

View File

@ -21,13 +21,21 @@ export interface PanelEditorProps<T = any> {
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;
@ -41,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;
} }
} }

View File

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

View File

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

View File

@ -133,6 +133,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)
@ -375,6 +378,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

View File

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

View File

@ -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 m *macaron.Macaron
context *m.ReqContext context *m.ReqContext
resp *httptest.ResponseRecorder resp *httptest.ResponseRecorder
handlerFunc handlerFunc handlerFunc handlerFunc
defaultHandler macaron.Handler defaultHandler macaron.Handler
req *http.Request req *http.Request
url string url string
userAuthTokenService *auth.FakeUserAuthTokenService
} }
func (sc *scenarioContext) exec() { func (sc *scenarioContext) exec() {

View File

@ -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()
} }

View File

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

View File

@ -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 {

View File

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

View File

@ -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 {

View File

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

110
pkg/api/user_token.go Normal file
View File

@ -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
pkg/api/user_token_test.go Normal file
View File

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

@ -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: "",

View File

@ -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: "",

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

@ -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]))
}

View File

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

View File

@ -241,6 +241,12 @@ type Cfg struct {
// User // User
EditorsCanOwn bool EditorsCanOwn bool
// Dataproxy
SendUserHeader bool
// DistributedCache
RemoteCacheOptions *RemoteCacheOptions
} }
type CommandLineArgs struct { type CommandLineArgs struct {
@ -601,6 +607,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")
@ -781,9 +788,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{}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import { PanelEditor } from '../panel_editor/PanelEditor';
import { PanelModel, DashboardModel } from '../state'; import { PanelModel, DashboardModel } from '../state';
import { PanelPlugin } from 'app/types'; import { PanelPlugin } from 'app/types';
import { PanelResizer } from './PanelResizer'; import { PanelResizer } from './PanelResizer';
import { PanelTypeChangedHook } from '@grafana/ui';
export interface Props { export interface Props {
panel: PanelModel; panel: PanelModel;
@ -91,7 +92,11 @@ export class DashboardPanel extends PureComponent<Props, State> {
this.props.panel.changeType(pluginId); this.props.panel.changeType(pluginId);
} else { } else {
panel.changeType(pluginId, plugin.exports.reactPanel.preserveOptions); let hook: PanelTypeChangedHook | null = null;
if (plugin.exports.reactPanel) {
hook = plugin.exports.reactPanel.panelTypeChangedHook;
}
panel.changeType(pluginId, hook);
} }
} }

View File

@ -3,7 +3,7 @@ import _ from 'lodash';
// Types // Types
import { Emitter } from 'app/core/utils/emitter'; import { Emitter } from 'app/core/utils/emitter';
import { DataQuery, TimeSeries, Threshold, ScopedVars } from '@grafana/ui'; import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui';
import { TableData } from '@grafana/ui/src'; import { TableData } from '@grafana/ui/src';
export interface GridPos { export interface GridPos {
@ -237,7 +237,7 @@ export class PanelModel {
}); });
} }
changeType(pluginId: string, preserveOptions?: any) { changeType(pluginId: string, hook?: PanelTypeChangedHook) {
const oldOptions: any = this.getOptionsToRemember(); const oldOptions: any = this.getOptionsToRemember();
const oldPluginId = this.type; const oldPluginId = this.type;
@ -255,9 +255,12 @@ export class PanelModel {
this.cachedPluginOptions[oldPluginId] = oldOptions; this.cachedPluginOptions[oldPluginId] = oldOptions;
this.restorePanelOptions(pluginId); this.restorePanelOptions(pluginId);
if (preserveOptions && oldOptions) { // Callback that can validate and migrate any existing settings
if (hook) {
this.options = this.options || {}; this.options = this.options || {};
Object.assign(this.options, preserveOptions(oldPluginId, oldOptions.options)); const old = oldOptions ? oldOptions.options : null;
Object.assign(this.options, hook(this.options, oldPluginId, old));
} }
} }

View File

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

View File

@ -8,10 +8,8 @@ export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel);
reactPanel.setEditor(BarGaugePanelEditor); reactPanel.setEditor(BarGaugePanelEditor);
reactPanel.setDefaults(defaults); reactPanel.setDefaults(defaults);
reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => { reactPanel.setPanelTypeChangedHook((options: BarGaugeOptions, prevPluginId?: string, prevOptions?: any) => {
const options: Partial<BarGaugeOptions> = {}; if (prevOptions && prevOptions.valueOptions) {
if (prevOptions.valueOptions) {
options.valueOptions = prevOptions.valueOptions; options.valueOptions = prevOptions.valueOptions;
options.thresholds = prevOptions.thresholds; options.thresholds = prevOptions.thresholds;
options.maxValue = prevOptions.maxValue; options.maxValue = prevOptions.maxValue;

View File

@ -8,10 +8,8 @@ export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel);
reactPanel.setEditor(GaugePanelEditor); reactPanel.setEditor(GaugePanelEditor);
reactPanel.setDefaults(defaults); reactPanel.setDefaults(defaults);
reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => { reactPanel.setPanelTypeChangedHook((options: GaugeOptions, prevPluginId?: string, prevOptions?: any) => {
const options: Partial<GaugeOptions> = {}; if (prevOptions && prevOptions.valueOptions) {
if (prevOptions.valueOptions) {
options.valueOptions = prevOptions.valueOptions; options.valueOptions = prevOptions.valueOptions;
options.thresholds = prevOptions.thresholds; options.thresholds = prevOptions.thresholds;
options.maxValue = prevOptions.maxValue; options.maxValue = prevOptions.maxValue;

View File

@ -8,3 +8,10 @@ export const reactPanel = new ReactPanelPlugin<TextOptions>(TextPanel);
reactPanel.setEditor(TextPanelEditor); reactPanel.setEditor(TextPanelEditor);
reactPanel.setDefaults(defaults); reactPanel.setDefaults(defaults);
reactPanel.setPanelTypeChangedHook((options: TextOptions, prevPluginId: string, prevOptions: any) => {
if (prevPluginId === 'text') {
return prevOptions as TextOptions;
}
return options;
});

View File

@ -0,0 +1,16 @@
#!/bin/bash
function exit_if_fail {
command=$@
echo "Executing '$command'"
eval $command
rc=$?
if [ $rc -ne 0 ]; then
echo "'$command' returned $rc."
exit $rc
fi
}
echo "running redis and memcache tests"
time exit_if_fail go test -tags=redis ./pkg/infra/remotecache/...
time exit_if_fail go test -tags=memcached ./pkg/infra/remotecache/...