diff --git a/.circleci/config.yml b/.circleci/config.yml index 69cea87dccd..da0e0665285 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -56,6 +56,20 @@ jobs: name: postgres integration tests command: './scripts/circle-test-postgres.sh' + cache-server-test: + docker: + - image: circleci/golang:1.11.5 + - image: circleci/redis:4-alpine + - image: memcached + working_directory: /go/src/github.com/grafana/grafana + steps: + - checkout + - run: dockerize -wait tcp://127.0.0.1:11211 -timeout 120s + - run: dockerize -wait tcp://127.0.0.1:6379 -timeout 120s + - run: + name: cache server tests + command: './scripts/circle-test-cache-servers.sh' + codespell: docker: - image: circleci/python @@ -545,6 +559,8 @@ workflows: filters: *filter-not-release-or-master - postgres-integration-test: filters: *filter-not-release-or-master + - cache-server-test: + filters: *filter-not-release-or-master - grafana-docker-pr: requires: - build @@ -554,4 +570,5 @@ workflows: - gometalinter - mysql-integration-test - postgres-integration-test + - cache-server-test filters: *filter-not-release-or-master diff --git a/CHANGELOG.md b/CHANGELOG.md index 5483529f75e..1f09ead1fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ * **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow) * **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683) * **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683) +* **VictorOps**: Adds more information to the victor ops notifiers [#15744](https://github.com/grafana/grafana/issues/15744), thx [@zhulongcheng](https://github.com/zhulongcheng) +* **Cache**: Adds support for using out of proc caching in the backend [#10816](https://github.com/grafana/grafana/issues/10816) +* **Dataproxy**: Make it possible to add user details to requests sent to the dataproxy [#6359](https://github.com/grafana/grafana/issues/6359) and [#15931](https://github.com/grafana/grafana/issues/15931) +* **Auth**: Support listing and revoking auth tokens via API [#15836](https://github.com/grafana/grafana/issues/15836) +* **Datasource**: Only log connection string in dev environment [#16001](https://github.com/grafana/grafana/issues/16001) ### Bug Fixes * **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506) diff --git a/conf/defaults.ini b/conf/defaults.ini index 044d8e59a7a..492525e6b5f 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -106,6 +106,17 @@ path = grafana.db # For "sqlite3" only. cache mode setting used for connecting to the database cache_mode = private +#################################### Cache server ############################# +[remote_cache] +# Either "redis", "memcached" or "database" default is "database" +type = database + +# cache connectionstring options +# database: will use Grafana primary database. +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana` +# memcache: 127.0.0.1:11211 +connstr = + #################################### Session ############################# [session] # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file" @@ -146,6 +157,9 @@ logging = false # How long the data proxy should wait before timing out default is 30 (seconds) timeout = 30 +# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false. +send_user_header = false + #################################### Analytics ########################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. diff --git a/conf/sample.ini b/conf/sample.ini index dc1e4fbde8e..fd414c2af47 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -102,6 +102,17 @@ log_queries = # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared) ;cache_mode = private +#################################### Cache server ############################# +[remote_cache] +# Either "redis", "memcached" or "database" default is "database" +;type = database + +# cache connectionstring options +# database: will use Grafana primary database. +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana` +# memcache: 127.0.0.1:11211 +;connstr = + #################################### Session #################################### [session] # Either "memory", "file", "redis", "mysql", "postgres", default is "file" @@ -133,6 +144,9 @@ log_queries = # How long the data proxy should wait before timing out default is 30 (seconds) ;timeout = 30 +# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false. +;send_user_header = false + #################################### Analytics #################################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. diff --git a/devenv/docker/blocks/redis/docker-compose.yaml b/devenv/docker/blocks/redis/docker-compose.yaml index 65071d4966b..fb56afaac1c 100644 --- a/devenv/docker/blocks/redis/docker-compose.yaml +++ b/devenv/docker/blocks/redis/docker-compose.yaml @@ -1,4 +1,4 @@ - memcached: + redis: image: redis:latest ports: - "6379:6379" diff --git a/docs/sources/enterprise/index.md b/docs/sources/enterprise/index.md index 5d524dcbee2..421e94cbf9f 100644 --- a/docs/sources/enterprise/index.md +++ b/docs/sources/enterprise/index.md @@ -38,6 +38,8 @@ With a Grafana Enterprise license you will get access to premium plugins, includ * [DataDog](https://grafana.com/plugins/grafana-datadog-datasource) * [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource) * [New Relic](https://grafana.com/plugins/grafana-newrelic-datasource) +* [Amazon Timestream](https://grafana.com/plugins/grafana-timestream-datasource) +* [Oracle Database](https://grafana.com/plugins/grafana-oracle-datasource) ## Try Grafana Enterprise diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index ed88fcfc509..9c7fd5207c3 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -105,6 +105,14 @@ region = us-west-2 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 Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place. diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index a27fd2aac14..c2d540c452b 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -341,3 +341,105 @@ Content-Type: application/json {"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100} ``` + +## Auth tokens for User + +`GET /api/admin/users/:id/auth-tokens` + +Return a list of all auth tokens (devices) that the user currently have logged in from. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +GET /api/admin/users/1/auth-tokens HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 361, + "isActive": false, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + "createdAt": "2019-03-05T21:22:54+01:00", + "seenAt": "2019-03-06T19:41:06+01:00" + }, + { + "id": 364, + "isActive": false, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + "createdAt": "2019-03-06T19:41:19+01:00", + "seenAt": "2019-03-06T19:41:21+01:00" + } +] +``` + +## Revoke auth token for User + +`POST /api/admin/users/:id/revoke-auth-token` + +Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in +and will be required to authenticate again upon next activity. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +POST /api/admin/users/1/revoke-auth-token HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "authTokenId": 364 +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "User auth token revoked" +} +``` + +## Logout User + +`POST /api/admin/users/:id/logout` + +Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in +and will be required to authenticate again upon next activity. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +POST /api/admin/users/1/logout HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "User auth token revoked" +} +``` diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 669e8003247..a81f608c2f5 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -478,3 +478,75 @@ Content-Type: application/json {"message":"Dashboard unstarred"} ``` + +## Auth tokens of the actual User + +`GET /api/user/auth-tokens` + +Return a list of all auth tokens (devices) that the actual user currently have logged in from. + +**Example Request**: + +```http +GET /api/user/auth-tokens HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 361, + "isActive": true, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + "createdAt": "2019-03-05T21:22:54+01:00", + "seenAt": "2019-03-06T19:41:06+01:00" + }, + { + "id": 364, + "isActive": false, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + "createdAt": "2019-03-06T19:41:19+01:00", + "seenAt": "2019-03-06T19:41:21+01:00" + } +] +``` + +## Revoke an auth token of the actual User + +`POST /api/user/revoke-auth-token` + +Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in +and will be required to authenticate again upon next activity. + +**Example Request**: + +```http +POST /api/user/revoke-auth-token HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "authTokenId": 364 +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "User auth token revoked" +} +``` diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 3d1b25979c3..d94bacc5779 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -179,7 +179,6 @@ Path to the certificate key file (if `protocol` is set to `https`). Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events to grafana log. -

@@ -262,6 +261,19 @@ Set to `true` to log the sql calls and execution times. For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared) Defaults to private. +
+ +## [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`
@@ -399,6 +411,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
+## [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. + +
+ ## [analytics] ### reporting_enabled diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index f8792441bbd..7be1b754865 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -163,7 +163,7 @@ Expected result from datasource.annotationQuery: "title": "Cluster outage", "time": 1457075272576, "text": "Joe causes brain split", - "tags": "joe, cluster, failure" + "tags": ["joe", "cluster", "failure"] } ] ``` diff --git a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts index 97ade6da7a5..16f4a20d139 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -120,7 +120,7 @@ $headings-line-height: ${theme.typography.lineHeight.sm} !default; $border-width: ${theme.border.width.sm} !default; $border-radius: ${theme.border.radius.md} !default; -$border-radius-lg: ${theme.border.radius.lg}!default; +$border-radius-lg: ${theme.border.radius.lg} !default; $border-radius-sm: ${theme.border.radius.sm} !default; // Page @@ -191,7 +191,6 @@ $btn-padding-y-lg: 11px !default; $btn-padding-x-xl: 21px !default; $btn-padding-y-xl: 11px !default; - $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; // sidemenu diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index bb588ca078d..6c42ecfceb9 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -21,13 +21,21 @@ export interface PanelEditorProps { onOptionsChange: (options: T) => void; } -export type PreservePanelOptionsHandler = (pluginId: string, prevOptions: any) => Partial; +/** + * Called before a panel is initalized + */ +export type PanelTypeChangedHook = ( + options: Partial, + prevPluginId?: string, + prevOptions?: any +) => Partial; export class ReactPanelPlugin { panel: ComponentClass>; editor?: ComponentClass>; defaults?: TOptions; - preserveOptions?: PreservePanelOptionsHandler; + + panelTypeChangedHook?: PanelTypeChangedHook; constructor(panel: ComponentClass>) { this.panel = panel; @@ -41,8 +49,12 @@ export class ReactPanelPlugin { this.defaults = defaults; } - setPreserveOptionsHandler(handler: PreservePanelOptionsHandler) { - this.preserveOptions = handler; + /** + * Called when the visualization changes. + * Lets you keep whatever settings made sense in the previous panel + */ + setPanelTypeChangedHook(v: PanelTypeChangedHook) { + this.panelTypeChangedHook = v; } } diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index c16c2f126f8..4ad8a2b84ab 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -110,3 +110,26 @@ func AdminDeleteUser(c *m.ReqContext) { c.JsonOK("User deleted") } + +// POST /api/admin/users/:id/logout +func (server *HTTPServer) AdminLogoutUser(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + + if c.UserId == userID { + return Error(400, "You cannot logout yourself", nil) + } + + return server.logoutUserFromAllDevicesInternal(userID) +} + +// GET /api/admin/users/:id/auth-tokens +func (server *HTTPServer) AdminGetUserAuthTokens(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + return server.getUserAuthTokensInternal(c, userID) +} + +// POST /api/admin/users/:id/revoke-auth-token +func (server *HTTPServer) AdminRevokeUserAuthToken(c *m.ReqContext, cmd m.RevokeAuthTokenCmd) Response { + userID := c.ParamsInt64(":id") + return server.revokeUserAuthTokenInternal(c, userID, cmd) +} diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 0b94a64b3fb..b94f09b0b75 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" . "github.com/smartystreets/goconvey/convey" ) @@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 400) }) }) + + Convey("When a server admin attempts to logout himself from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + adminLogoutUserScenario("Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + + Convey("When a server admin attempts to logout a non-existing user from all devices", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + adminLogoutUserScenario("Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When a server admin attempts to revoke an auth token for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + + adminRevokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When a server admin gets auth tokens for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + adminGetUserAuthTokensScenario("Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) { + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) } func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) { @@ -48,3 +105,84 @@ func putAdminScenario(desc string, url string, routePattern string, role m.RoleT fn(sc) }) } + +func adminLogoutUserScenario(desc string, url string, routePattern string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: auth.NewFakeUserAuthTokenService(), + } + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminLogoutUser(c) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func adminRevokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminRevokeUserAuthToken(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func adminGetUserAuthTokensScenario(desc string, url string, routePattern string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminGetUserAuthTokens(c) + }) + + sc.m.Get(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 81ea83eae61..f3dc35b6b06 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -133,6 +133,9 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Get("/preferences", Wrap(GetUserPreferences)) userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences)) + + userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens)) + userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) }) // users (admin permission required) @@ -375,6 +378,10 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) adminRoute.Get("/stats", AdminGetStats) adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts)) + + adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser)) + adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens)) + adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) }, reqGrafanaAdmin) // rendering diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index d77d9d87b4a..abee55711b0 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -48,18 +48,18 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) { handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)) } } - handlers = append(handlers, AppPluginRoute(route, plugin.Id)) + handlers = append(handlers, AppPluginRoute(route, plugin.Id, hs)) r.Route(url, route.Method, handlers...) log.Debug("Plugins: Adding proxy route %s", url) } } } -func AppPluginRoute(route *plugins.AppPluginRoute, appID string) macaron.Handler { +func AppPluginRoute(route *plugins.AppPluginRoute, appID string, hs *HTTPServer) macaron.Handler { return func(c *m.ReqContext) { path := c.Params("*") - proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID) + proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg) proxy.Transport = pluginProxyTransport proxy.ServeHTTP(c.Resp, c.Req.Request) } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 3f3a50aae69..4e0b0dcd998 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" @@ -94,13 +95,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string + userAuthTokenService *auth.FakeUserAuthTokenService } func (sc *scenarioContext) exec() { diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 54a744fccdc..48f9c73c934 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -31,7 +31,7 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { // macaron does not include trailing slashes when resolving a wildcard path proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*")) - proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath) + proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath, hs.Cfg) proxy.HandleRequest() } diff --git a/pkg/api/dtos/user_token.go b/pkg/api/dtos/user_token.go new file mode 100644 index 00000000000..1542421e2f6 --- /dev/null +++ b/pkg/api/dtos/user_token.go @@ -0,0 +1,12 @@ +package dtos + +import "time" + +type UserToken struct { + Id int64 `json:"id"` + IsActive bool `json:"isActive"` + ClientIp string `json:"clientIp"` + UserAgent string `json:"userAgent"` + CreatedAt time.Time `json:"createdAt"` + SeenAt time.Time `json:"seenAt"` +} diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index b1950998297..3aec988f9e3 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -34,13 +34,14 @@ type DataSourceProxy struct { proxyPath string route *plugins.AppPluginRoute plugin *plugins.DataSourcePlugin + cfg *setting.Cfg } type httpClient interface { Do(req *http.Request) (*http.Response, error) } -func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string) *DataSourceProxy { +func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string, cfg *setting.Cfg) *DataSourceProxy { targetURL, _ := url.Parse(ds.Url) return &DataSourceProxy{ @@ -49,6 +50,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx ctx: ctx, proxyPath: proxyPath, targetUrl: targetURL, + cfg: cfg, } } @@ -170,6 +172,10 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { req.Header.Add("Authorization", dsAuth) } + if proxy.cfg.SendUserHeader && !proxy.ctx.SignedInUser.IsAnonymous { + req.Header.Add("X-Grafana-User", proxy.ctx.SignedInUser.Login) + } + // clear cookie header, except for whitelisted cookies var keptCookies []*http.Cookie if proxy.ds.JsonData != nil { diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index c9be169565f..bfad7d5670d 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -81,7 +81,7 @@ func TestDSRouteRule(t *testing.T) { } Convey("When matching route path", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{}) proxy.route = plugin.Routes[0] ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds) @@ -92,7 +92,7 @@ func TestDSRouteRule(t *testing.T) { }) Convey("When matching route path and has dynamic url", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method", &setting.Cfg{}) proxy.route = plugin.Routes[3] ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds) @@ -104,20 +104,20 @@ func TestDSRouteRule(t *testing.T) { Convey("Validating request", func() { Convey("plugin route with valid role", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{}) err := proxy.validateRequest() So(err, ShouldBeNil) }) Convey("plugin route with admin role and user is editor", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{}) err := proxy.validateRequest() So(err, ShouldNotBeNil) }) Convey("plugin route with admin role and user is admin", func() { ctx.SignedInUser.OrgRole = m.ROLE_ADMIN - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{}) err := proxy.validateRequest() So(err, ShouldBeNil) }) @@ -186,7 +186,7 @@ func TestDSRouteRule(t *testing.T) { So(err, ShouldBeNil) client = newFakeHTTPClient(json) - proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1") + proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{}) proxy1.route = plugin.Routes[0] ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds) @@ -200,7 +200,7 @@ func TestDSRouteRule(t *testing.T) { req, _ := http.NewRequest("GET", "http://localhost/asd", nil) client = newFakeHTTPClient(json2) - proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2") + proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", &setting.Cfg{}) proxy2.route = plugin.Routes[1] ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds) @@ -215,7 +215,7 @@ func TestDSRouteRule(t *testing.T) { req, _ := http.NewRequest("GET", "http://localhost/asd", nil) client = newFakeHTTPClient([]byte{}) - proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1") + proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{}) proxy3.route = plugin.Routes[0] ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds) @@ -236,7 +236,7 @@ func TestDSRouteRule(t *testing.T) { ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE} ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "/render") + proxy := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{}) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) So(err, ShouldBeNil) @@ -261,7 +261,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) So(err, ShouldBeNil) @@ -291,7 +291,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) requestURL, _ := url.Parse("http://grafana.com/sub") req := http.Request{URL: requestURL, Header: make(http.Header)} @@ -317,7 +317,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) requestURL, _ := url.Parse("http://grafana.com/sub") req := http.Request{URL: requestURL, Header: make(http.Header)} @@ -347,7 +347,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) requestURL, _ := url.Parse("http://grafana.com/sub") req := http.Request{URL: requestURL, Header: make(http.Header)} @@ -369,7 +369,7 @@ func TestDSRouteRule(t *testing.T) { Url: "http://host/root/", } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/") + proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{}) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) req.Header.Add("Origin", "grafana.com") req.Header.Add("Referer", "grafana.com") @@ -388,9 +388,68 @@ func TestDSRouteRule(t *testing.T) { So(req.Header.Get("X-Canary"), ShouldEqual, "stillthere") }) }) + + Convey("When SendUserHeader config is enabled", func() { + req := getDatasourceProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: true}, + ) + Convey("Should add header with username", func() { + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user") + }) + }) + + Convey("When SendUserHeader config is disabled", func() { + req := getDatasourceProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: false}, + ) + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) + + Convey("When SendUserHeader config is enabled but user is anonymous", func() { + req := getDatasourceProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{IsAnonymous: true}, + }, + &setting.Cfg{SendUserHeader: true}, + ) + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) }) } +// getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. +func getDatasourceProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request { + plugin := &plugins.DataSourcePlugin{} + + ds := &m.DataSource{ + Type: "custom", + Url: "http://host/root/", + } + + proxy := NewDataSourceProxy(ds, plugin, ctx, "", cfg) + req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) + So(err, ShouldBeNil) + + proxy.getDirector()(req) + return req +} + type httpClientStub struct { fakeBody []byte } diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index 4cf1dbd7cde..5ee59017a82 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -2,6 +2,7 @@ package pluginproxy import ( "encoding/json" + "github.com/grafana/grafana/pkg/setting" "net" "net/http" "net/http/httputil" @@ -37,7 +38,7 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http. return result, err } -func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string) *httputil.ReverseProxy { +func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy { targetURL, _ := url.Parse(route.Url) director := func(req *http.Request) { @@ -79,6 +80,10 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl req.Header.Add("X-Grafana-Context", string(ctxJson)) + if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous { + req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login) + } + if len(route.Headers) > 0 { headers, err := getHeaders(route, ctx.OrgId, appID) if err != nil { diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index 424c3fd670c..e4a4fdb25ba 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -1,6 +1,7 @@ package pluginproxy import ( + "net/http" "testing" "github.com/grafana/grafana/pkg/bus" @@ -44,4 +45,59 @@ func TestPluginProxy(t *testing.T) { }) }) + Convey("When SendUserHeader config is enabled", t, func() { + req := getPluginProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: true}, + ) + + Convey("Should add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user") + }) + }) + + Convey("When SendUserHeader config is disabled", t, func() { + req := getPluginProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: false}, + ) + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) + + Convey("When SendUserHeader config is enabled but user is anonymous", t, func() { + req := getPluginProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{IsAnonymous: true}, + }, + &setting.Cfg{SendUserHeader: true}, + ) + + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) +} + +// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. +func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request { + route := &plugins.AppPluginRoute{} + proxy := NewApiPluginProxy(ctx, "", route, "", cfg) + + req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) + So(err, ShouldBeNil) + proxy.Director(req) + return req } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go new file mode 100644 index 00000000000..2f74eedea5d --- /dev/null +++ b/pkg/api/user_token.go @@ -0,0 +1,110 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// GET /api/user/auth-tokens +func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response { + return server.getUserAuthTokensInternal(c, c.UserId) +} + +// POST /api/user/revoke-auth-token +func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { + return server.revokeUserAuthTokenInternal(c, c.UserId, cmd) +} + +func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Could not read user from database", err) + } + + err := server.AuthTokenService.RevokeAllUserTokens(userID) + if err != nil { + return Error(500, "Failed to logout user", err) + } + + return JSON(200, util.DynMap{ + "message": "User logged out", + }) +} + +func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Failed to get user", err) + } + + tokens, err := server.AuthTokenService.GetUserTokens(userID) + if err != nil { + return Error(500, "Failed to get user auth tokens", err) + } + + result := []*dtos.UserToken{} + for _, token := range tokens { + isActive := false + if c.UserToken != nil && c.UserToken.Id == token.Id { + isActive = true + } + + result = append(result, &dtos.UserToken{ + Id: token.Id, + IsActive: isActive, + ClientIp: token.ClientIp, + UserAgent: token.UserAgent, + CreatedAt: time.Unix(token.CreatedAt, 0), + SeenAt: time.Unix(token.SeenAt, 0), + }) + } + + return JSON(200, result) +} + +func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Failed to get user", err) + } + + token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId) + if err != nil { + if err == models.ErrUserTokenNotFound { + return Error(404, "User auth token not found", err) + } + return Error(500, "Failed to get user auth token", err) + } + + if c.UserToken != nil && c.UserToken.Id == token.Id { + return Error(400, "Cannot revoke active user auth token", nil) + } + + err = server.AuthTokenService.RevokeToken(token) + if err != nil { + if err == models.ErrUserTokenNotFound { + return Error(404, "User auth token not found", err) + } + return Error(500, "Failed to revoke user auth token", err) + } + + return JSON(200, util.DynMap{ + "message": "User auth token revoked", + }) +} diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go new file mode 100644 index 00000000000..111070dca92 --- /dev/null +++ b/pkg/api/user_token_test.go @@ -0,0 +1,294 @@ +package api + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserTokenApiEndpoint(t *testing.T) { + Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + + revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When current user gets auth tokens for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) { + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When logout an existing user from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: 200} + return nil + }) + + logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When logout a non-existing user from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + return m.ErrUserNotFound + }) + + logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("When revoke an auth token for a user", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: 200} + return nil + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &m.UserToken{Id: 1} + + revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) { + sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) { + return &m.UserToken{Id: 2}, nil + } + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When revoke the active auth token used by himself", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &m.UserToken{Id: 2} + + revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) { + sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) { + return token, nil + } + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + + Convey("When gets auth tokens for a user", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + currentToken := &m.UserToken{Id: 1} + + getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) { + tokens := []*m.UserToken{ + { + Id: 1, + ClientIp: "127.0.0.1", + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + CreatedAt: time.Now().Unix(), + SeenAt: time.Now().Unix(), + }, + { + Id: 2, + ClientIp: "127.0.0.2", + UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + CreatedAt: time.Now().Unix(), + SeenAt: time.Now().Unix(), + }, + } + sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) { + return tokens, nil + } + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + result := sc.ToJSON() + So(result.MustArray(), ShouldHaveLength, 2) + + resultOne := result.GetIndex(0) + So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id) + So(resultOne.Get("isActive").MustBool(), ShouldBeTrue) + So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1") + So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36") + So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339)) + So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339)) + + resultTwo := result.GetIndex(1) + So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id) + So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse) + So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2") + So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1") + So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339)) + So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339)) + }) + }) +} + +func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = userId + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.RevokeUserAuthToken(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = userId + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.GetUserAuthTokens(c) + }) + + sc.m.Get(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: auth.NewFakeUserAuthTokenService(), + } + + sc := setupScenarioContext("/") + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.logoutUserFromAllDevicesInternal(userId) + }) + + sc.m.Post("/", sc.defaultHandler) + + fn(sc) + }) +} + +func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext("/") + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + sc.context.UserToken = token + + return hs.revokeUserAuthTokenInternal(c, userId, cmd) + }) + + sc.m.Post("/", sc.defaultHandler) + + fn(sc) + }) +} + +func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext("/") + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + sc.context.UserToken = token + + return hs.getUserAuthTokensInternal(c, TestUserID) + }) + + sc.m.Get("/", sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 53218147ae0..c10212329cf 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -29,6 +29,7 @@ import ( // self registering services _ "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/infra/metrics" + _ "github.com/grafana/grafana/pkg/infra/remotecache" _ "github.com/grafana/grafana/pkg/infra/serverlock" _ "github.com/grafana/grafana/pkg/infra/tracing" _ "github.com/grafana/grafana/pkg/infra/usagestats" diff --git a/pkg/infra/remotecache/database_storage.go b/pkg/infra/remotecache/database_storage.go new file mode 100644 index 00000000000..1c39d74d800 --- /dev/null +++ b/pkg/infra/remotecache/database_storage.go @@ -0,0 +1,126 @@ +package remotecache + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +var getTime = time.Now + +const databaseCacheType = "database" + +type databaseCache struct { + SQLStore *sqlstore.SqlStore + log log.Logger +} + +func newDatabaseCache(sqlstore *sqlstore.SqlStore) *databaseCache { + dc := &databaseCache{ + SQLStore: sqlstore, + log: log.New("remotecache.database"), + } + + return dc +} + +func (dc *databaseCache) Run(ctx context.Context) error { + ticker := time.NewTicker(time.Minute * 10) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + dc.internalRunGC() + } + } +} + +func (dc *databaseCache) internalRunGC() { + now := getTime().Unix() + sql := `DELETE FROM cache_data WHERE (? - created_at) >= expires AND expires <> 0` + + _, err := dc.SQLStore.NewSession().Exec(sql, now) + if err != nil { + dc.log.Error("failed to run garbage collect", "error", err) + } +} + +func (dc *databaseCache) Get(key string) (interface{}, error) { + cacheHit := CacheData{} + session := dc.SQLStore.NewSession() + defer session.Close() + + exist, err := session.Where("cache_key= ?", key).Get(&cacheHit) + + if err != nil { + return nil, err + } + + if !exist { + return nil, ErrCacheItemNotFound + } + + if cacheHit.Expires > 0 { + existedButExpired := getTime().Unix()-cacheHit.CreatedAt >= cacheHit.Expires + if existedButExpired { + _ = dc.Delete(key) //ignore this error since we will return `ErrCacheItemNotFound` anyway + return nil, ErrCacheItemNotFound + } + } + + item := &cachedItem{} + if err = decodeGob(cacheHit.Data, item); err != nil { + return nil, err + } + + return item.Val, nil +} + +func (dc *databaseCache) Set(key string, value interface{}, expire time.Duration) error { + item := &cachedItem{Val: value} + data, err := encodeGob(item) + if err != nil { + return err + } + + session := dc.SQLStore.NewSession() + + var cacheHit CacheData + has, err := session.Where("cache_key = ?", key).Get(&cacheHit) + if err != nil { + return err + } + + var expiresInSeconds int64 + if expire != 0 { + expiresInSeconds = int64(expire) / int64(time.Second) + } + + // insert or update depending on if item already exist + if has { + sql := `UPDATE cache_data SET data=?, created=?, expire=? WHERE cache_key='?'` + _, err = session.Exec(sql, data, getTime().Unix(), expiresInSeconds, key) + } else { + sql := `INSERT INTO cache_data (cache_key,data,created_at,expires) VALUES(?,?,?,?)` + _, err = session.Exec(sql, key, data, getTime().Unix(), expiresInSeconds) + } + + return err +} + +func (dc *databaseCache) Delete(key string) error { + sql := "DELETE FROM cache_data WHERE cache_key=?" + _, err := dc.SQLStore.NewSession().Exec(sql, key) + + return err +} + +type CacheData struct { + CacheKey string + Data []byte + Expires int64 + CreatedAt int64 +} diff --git a/pkg/infra/remotecache/database_storage_test.go b/pkg/infra/remotecache/database_storage_test.go new file mode 100644 index 00000000000..d15e26fd07f --- /dev/null +++ b/pkg/infra/remotecache/database_storage_test.go @@ -0,0 +1,56 @@ +package remotecache + +import ( + "testing" + "time" + + "github.com/bmizerany/assert" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func TestDatabaseStorageGarbageCollection(t *testing.T) { + sqlstore := sqlstore.InitTestDB(t) + + db := &databaseCache{ + SQLStore: sqlstore, + log: log.New("remotecache.database"), + } + + obj := &CacheableStruct{String: "foolbar"} + + //set time.now to 2 weeks ago + var err error + getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } + err = db.Set("key1", obj, 1000*time.Second) + assert.Equal(t, err, nil) + + err = db.Set("key2", obj, 1000*time.Second) + assert.Equal(t, err, nil) + + err = db.Set("key3", obj, 1000*time.Second) + assert.Equal(t, err, nil) + + // insert object that should never expire + db.Set("key4", obj, 0) + + getTime = time.Now + db.Set("key5", obj, 1000*time.Second) + + //run GC + db.internalRunGC() + + //try to read values + _, err = db.Get("key1") + assert.Equal(t, err, ErrCacheItemNotFound, "expected cache item not found. got: ", err) + _, err = db.Get("key2") + assert.Equal(t, err, ErrCacheItemNotFound) + _, err = db.Get("key3") + assert.Equal(t, err, ErrCacheItemNotFound) + + _, err = db.Get("key4") + assert.Equal(t, err, nil) + _, err = db.Get("key5") + assert.Equal(t, err, nil) +} diff --git a/pkg/infra/remotecache/memcached_storage.go b/pkg/infra/remotecache/memcached_storage.go new file mode 100644 index 00000000000..5424a05ad02 --- /dev/null +++ b/pkg/infra/remotecache/memcached_storage.go @@ -0,0 +1,71 @@ +package remotecache + +import ( + "time" + + "github.com/bradfitz/gomemcache/memcache" + "github.com/grafana/grafana/pkg/setting" +) + +const memcachedCacheType = "memcached" + +type memcachedStorage struct { + c *memcache.Client +} + +func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage { + return &memcachedStorage{ + c: memcache.New(opts.ConnStr), + } +} + +func newItem(sid string, data []byte, expire int32) *memcache.Item { + return &memcache.Item{ + Key: sid, + Value: data, + Expiration: expire, + } +} + +// Set sets value to given key in the cache. +func (s *memcachedStorage) Set(key string, val interface{}, expires time.Duration) error { + item := &cachedItem{Val: val} + bytes, err := encodeGob(item) + if err != nil { + return err + } + + var expiresInSeconds int64 + if expires != 0 { + expiresInSeconds = int64(expires) / int64(time.Second) + } + + memcachedItem := newItem(key, bytes, int32(expiresInSeconds)) + return s.c.Set(memcachedItem) +} + +// Get gets value by given key in the cache. +func (s *memcachedStorage) Get(key string) (interface{}, error) { + memcachedItem, err := s.c.Get(key) + if err != nil && err.Error() == "memcache: cache miss" { + return nil, ErrCacheItemNotFound + } + + if err != nil { + return nil, err + } + + item := &cachedItem{} + + err = decodeGob(memcachedItem.Value, item) + if err != nil { + return nil, err + } + + return item.Val, nil +} + +// Delete delete a key from the cache +func (s *memcachedStorage) Delete(key string) error { + return s.c.Delete(key) +} diff --git a/pkg/infra/remotecache/memcached_storage_integration_test.go b/pkg/infra/remotecache/memcached_storage_integration_test.go new file mode 100644 index 00000000000..d1d82468644 --- /dev/null +++ b/pkg/infra/remotecache/memcached_storage_integration_test.go @@ -0,0 +1,15 @@ +// +build memcached + +package remotecache + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" +) + +func TestMemcachedCacheStorage(t *testing.T) { + opts := &setting.RemoteCacheOptions{Name: memcachedCacheType, ConnStr: "localhost:11211"} + client := createTestClient(t, opts, nil) + runTestsForClient(t, client) +} diff --git a/pkg/infra/remotecache/redis_storage.go b/pkg/infra/remotecache/redis_storage.go new file mode 100644 index 00000000000..bd54b843119 --- /dev/null +++ b/pkg/infra/remotecache/redis_storage.go @@ -0,0 +1,62 @@ +package remotecache + +import ( + "time" + + "github.com/grafana/grafana/pkg/setting" + redis "gopkg.in/redis.v2" +) + +const redisCacheType = "redis" + +type redisStorage struct { + c *redis.Client +} + +func newRedisStorage(opts *setting.RemoteCacheOptions) *redisStorage { + opt := &redis.Options{ + Network: "tcp", + Addr: opts.ConnStr, + } + return &redisStorage{c: redis.NewClient(opt)} +} + +// Set sets value to given key in session. +func (s *redisStorage) Set(key string, val interface{}, expires time.Duration) error { + item := &cachedItem{Val: val} + value, err := encodeGob(item) + if err != nil { + return err + } + + status := s.c.SetEx(key, expires, string(value)) + return status.Err() +} + +// Get gets value by given key in session. +func (s *redisStorage) Get(key string) (interface{}, error) { + v := s.c.Get(key) + + item := &cachedItem{} + err := decodeGob([]byte(v.Val()), item) + + if err == nil { + return item.Val, nil + } + + if err.Error() == "EOF" { + return nil, ErrCacheItemNotFound + } + + if err != nil { + return nil, err + } + + return item.Val, nil +} + +// Delete delete a key from session. +func (s *redisStorage) Delete(key string) error { + cmd := s.c.Del(key) + return cmd.Err() +} diff --git a/pkg/infra/remotecache/redis_storage_integration_test.go b/pkg/infra/remotecache/redis_storage_integration_test.go new file mode 100644 index 00000000000..8d54fc9ff14 --- /dev/null +++ b/pkg/infra/remotecache/redis_storage_integration_test.go @@ -0,0 +1,16 @@ +// +build redis + +package remotecache + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" +) + +func TestRedisCacheStorage(t *testing.T) { + + opts := &setting.RemoteCacheOptions{Name: redisCacheType, ConnStr: "localhost:6379"} + client := createTestClient(t, opts, nil) + runTestsForClient(t, client) +} diff --git a/pkg/infra/remotecache/remotecache.go b/pkg/infra/remotecache/remotecache.go new file mode 100644 index 00000000000..9219fa33a08 --- /dev/null +++ b/pkg/infra/remotecache/remotecache.go @@ -0,0 +1,133 @@ +package remotecache + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "time" + + "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + + "github.com/grafana/grafana/pkg/registry" +) + +var ( + // ErrCacheItemNotFound is returned if cache does not exist + ErrCacheItemNotFound = errors.New("cache item not found") + + // ErrInvalidCacheType is returned if the type is invalid + ErrInvalidCacheType = errors.New("invalid remote cache name") + + defaultMaxCacheExpiration = time.Hour * 24 +) + +func init() { + registry.RegisterService(&RemoteCache{}) +} + +// CacheStorage allows the caller to set, get and delete items in the cache. +// Cached items are stored as byte arrays and marshalled using "encoding/gob" +// so any struct added to the cache needs to be registred with `remotecache.Register` +// ex `remotecache.Register(CacheableStruct{})`` +type CacheStorage interface { + // Get reads object from Cache + Get(key string) (interface{}, error) + + // Set sets an object into the cache. if `expire` is set to zero it will default to 24h + Set(key string, value interface{}, expire time.Duration) error + + // Delete object from cache + Delete(key string) error +} + +// RemoteCache allows Grafana to cache data outside its own process +type RemoteCache struct { + log log.Logger + client CacheStorage + SQLStore *sqlstore.SqlStore `inject:""` + Cfg *setting.Cfg `inject:""` +} + +// Get reads object from Cache +func (ds *RemoteCache) Get(key string) (interface{}, error) { + return ds.client.Get(key) +} + +// Set sets an object into the cache. if `expire` is set to zero it will default to 24h +func (ds *RemoteCache) Set(key string, value interface{}, expire time.Duration) error { + if expire == 0 { + expire = defaultMaxCacheExpiration + } + + return ds.client.Set(key, value, expire) +} + +// Delete object from cache +func (ds *RemoteCache) Delete(key string) error { + return ds.client.Delete(key) +} + +// Init initializes the service +func (ds *RemoteCache) Init() error { + ds.log = log.New("cache.remote") + var err error + ds.client, err = createClient(ds.Cfg.RemoteCacheOptions, ds.SQLStore) + return err +} + +// Run start the backend processes for cache clients +func (ds *RemoteCache) Run(ctx context.Context) error { + //create new interface if more clients need GC jobs + backgroundjob, ok := ds.client.(registry.BackgroundService) + if ok { + return backgroundjob.Run(ctx) + } + + <-ctx.Done() + return ctx.Err() +} + +func createClient(opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) (CacheStorage, error) { + if opts.Name == redisCacheType { + return newRedisStorage(opts), nil + } + + if opts.Name == memcachedCacheType { + return newMemcachedStorage(opts), nil + } + + if opts.Name == databaseCacheType { + return newDatabaseCache(sqlstore), nil + } + + return nil, ErrInvalidCacheType +} + +// Register records a type, identified by a value for that type, under its +// internal type name. That name will identify the concrete type of a value +// sent or received as an interface variable. Only types that will be +// transferred as implementations of interface values need to be registered. +// Expecting to be used only during initialization, it panics if the mapping +// between types and names is not a bijection. +func Register(value interface{}) { + gob.Register(value) +} + +type cachedItem struct { + Val interface{} +} + +func encodeGob(item *cachedItem) ([]byte, error) { + buf := bytes.NewBuffer(nil) + err := gob.NewEncoder(buf).Encode(item) + return buf.Bytes(), err +} + +func decodeGob(data []byte, out *cachedItem) error { + buf := bytes.NewBuffer(data) + return gob.NewDecoder(buf).Decode(&out) +} diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go new file mode 100644 index 00000000000..bf1675ec87c --- /dev/null +++ b/pkg/infra/remotecache/remotecache_test.go @@ -0,0 +1,93 @@ +package remotecache + +import ( + "testing" + "time" + + "github.com/bmizerany/assert" + + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +type CacheableStruct struct { + String string + Int64 int64 +} + +func init() { + Register(CacheableStruct{}) +} + +func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) CacheStorage { + t.Helper() + + dc := &RemoteCache{ + SQLStore: sqlstore, + Cfg: &setting.Cfg{ + RemoteCacheOptions: opts, + }, + } + + err := dc.Init() + if err != nil { + t.Fatalf("failed to init client for test. error: %v", err) + } + + return dc +} + +func TestCachedBasedOnConfig(t *testing.T) { + + cfg := setting.NewCfg() + cfg.Load(&setting.CommandLineArgs{ + HomePath: "../../../", + }) + + client := createTestClient(t, cfg.RemoteCacheOptions, sqlstore.InitTestDB(t)) + runTestsForClient(t, client) +} + +func TestInvalidCacheTypeReturnsError(t *testing.T) { + _, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil) + assert.Equal(t, err, ErrInvalidCacheType) +} + +func runTestsForClient(t *testing.T, client CacheStorage) { + canPutGetAndDeleteCachedObjects(t, client) + canNotFetchExpiredItems(t, client) +} + +func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) { + cacheableStruct := CacheableStruct{String: "hej", Int64: 2000} + + err := client.Set("key1", cacheableStruct, 0) + assert.Equal(t, err, nil, "expected nil. got: ", err) + + data, err := client.Get("key1") + s, ok := data.(CacheableStruct) + + assert.Equal(t, ok, true) + assert.Equal(t, s.String, "hej") + assert.Equal(t, s.Int64, int64(2000)) + + err = client.Delete("key1") + assert.Equal(t, err, nil) + + _, err = client.Get("key1") + assert.Equal(t, err, ErrCacheItemNotFound) +} + +func canNotFetchExpiredItems(t *testing.T, client CacheStorage) { + cacheableStruct := CacheableStruct{String: "hej", Int64: 2000} + + err := client.Set("key1", cacheableStruct, time.Second) + assert.Equal(t, err, nil) + + //not sure how this can be avoided when testing redis/memcached :/ + <-time.After(time.Second + time.Millisecond) + + // should not be able to read that value since its expired + _, err = client.Get("key1") + assert.Equal(t, err, ErrCacheItemNotFound) +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 1fbd303bebd..2fc8e0c456f 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -11,6 +11,7 @@ import ( msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: unhashedToken, @@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", }, nil } - sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { + sc.userAuthTokenService.TryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil } @@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return nil, m.ErrUserTokenNotFound } @@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { })) session.Init(&msession.Options{}, 0) - sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} @@ -595,7 +596,7 @@ type scenarioContext struct { handlerFunc handlerFunc defaultHandler macaron.Handler url string - userAuthTokenService *fakeUserAuthTokenService + userAuthTokenService *auth.FakeUserAuthTokenService req *http.Request } @@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) - -type fakeUserAuthTokenService struct { - createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error) - tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error) - lookupTokenProvider func(unhashedToken string) (*m.UserToken, error) - revokeTokenProvider func(token *m.UserToken) error - activeAuthTokenCount func() (int64, error) -} - -func newFakeUserAuthTokenService() *fakeUserAuthTokenService { - return &fakeUserAuthTokenService{ - createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) { - return &m.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - }, - tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) { - return false, nil - }, - lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - }, - revokeTokenProvider: func(token *m.UserToken) error { - return nil - }, - activeAuthTokenCount: func() (int64, error) { - return 10, nil - }, - } -} - -func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) { - return s.createTokenProvider(userId, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) { - return s.lookupTokenProvider(unhashedToken) -} - -func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) { - return s.tryRotateTokenProvider(token, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error { - return s.revokeTokenProvider(token) -} - -func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) { - return s.activeAuthTokenCount() -} diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index e01d1a68d21..fe5b2736035 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 0, UnhashedToken: "", @@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 52b696cf037..0ba42e708bc 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -3,6 +3,7 @@ package middleware import ( "testing" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/bus" @@ -36,7 +37,7 @@ func TestMiddlewareQuota(t *testing.T) { }, } - fakeAuthTokenService := newFakeUserAuthTokenService() + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() qs := "a.QuotaService{ AuthTokenService: fakeAuthTokenService, } @@ -87,7 +88,7 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 6736d699a39..00f3b7a3032 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" macaron "gopkg.in/macaron.v1" @@ -62,7 +63,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine sc.m.Use(OrgRedirect()) diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 388bc2dd4a2..8c3e7985995 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -1,6 +1,8 @@ package models -import "errors" +import ( + "errors" +) // Typed errors var ( @@ -23,11 +25,18 @@ type UserToken struct { UnhashedToken string } +type RevokeAuthTokenCmd struct { + AuthTokenId int64 `json:"authTokenId"` +} + // UserTokenService are used for generating and validating user tokens type UserTokenService interface { CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error) LookupToken(unhashedToken string) (*UserToken, error) TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error) RevokeToken(token *UserToken) error + RevokeAllUserTokens(userId int64) error ActiveTokenCount() (int64, error) + GetUserToken(userId, userTokenId int64) (*UserToken, error) + GetUserTokens(userId int64) ([]*UserToken, error) } diff --git a/pkg/services/alerting/notifiers/victorops.go b/pkg/services/alerting/notifiers/victorops.go index 3093aec9957..f33b3f4019e 100644 --- a/pkg/services/alerting/notifiers/victorops.go +++ b/pkg/services/alerting/notifiers/victorops.go @@ -92,14 +92,29 @@ func (this *VictoropsNotifier) Notify(evalContext *alerting.EvalContext) error { messageType = AlertStateRecovery } + fields := make(map[string]interface{}, 0) + fieldLimitCount := 4 + for index, evt := range evalContext.EvalMatches { + fields[evt.Metric] = evt.Value + if index > fieldLimitCount { + break + } + } + bodyJSON := simplejson.New() bodyJSON.Set("message_type", messageType) bodyJSON.Set("entity_id", evalContext.Rule.Name) + bodyJSON.Set("entity_display_name", evalContext.GetNotificationTitle()) bodyJSON.Set("timestamp", time.Now().Unix()) bodyJSON.Set("state_start_time", evalContext.StartTime.Unix()) bodyJSON.Set("state_message", evalContext.Rule.Message) bodyJSON.Set("monitoring_tool", "Grafana v"+setting.BuildVersion) bodyJSON.Set("alert_url", ruleUrl) + bodyJSON.Set("metrics", fields) + + if evalContext.Error != nil { + bodyJSON.Set("error_message", evalContext.Error.Error()) + } if evalContext.ImagePublicUrl != "" { bodyJSON.Set("image_url", evalContext.ImagePublicUrl) diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index 648575d54cd..255866a9ba0 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -221,6 +221,57 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error { return nil } +func (s *UserAuthTokenService) RevokeAllUserTokens(userId int64) error { + sql := `DELETE from user_auth_token WHERE user_id = ?` + res, err := s.SQLStore.NewSession().Exec(sql, userId) + if err != nil { + return err + } + + affected, err := res.RowsAffected() + if err != nil { + return err + } + + s.log.Debug("all user tokens for user revoked", "userId", userId, "count", affected) + + return nil +} + +func (s *UserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) { + var token userAuthToken + exists, err := s.SQLStore.NewSession().Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token) + if err != nil { + return nil, err + } + + if !exists { + return nil, models.ErrUserTokenNotFound + } + + var result models.UserToken + token.toUserToken(&result) + + return &result, nil +} + +func (s *UserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) { + var tokens []*userAuthToken + err := s.SQLStore.NewSession().Where("user_id = ? AND created_at > ? AND rotated_at > ?", userId, s.createdAfterParam(), s.rotatedAfterParam()).Find(&tokens) + if err != nil { + return nil, err + } + + result := []*models.UserToken{} + for _, token := range tokens { + var userToken models.UserToken + token.toUserToken(&userToken) + result = append(result, &userToken) + } + + return result, nil +} + func (s *UserAuthTokenService) createdAfterParam() int64 { tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour return getTime().Add(-tokenMaxLifetime).Unix() diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 49e7acc3a5b..33eb309ad18 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -75,6 +75,47 @@ func TestUserAuthToken(t *testing.T) { err = userAuthTokenService.RevokeToken(userToken) So(err, ShouldEqual, models.ErrUserTokenNotFound) }) + + Convey("When creating an additional token", func() { + userToken2, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(userToken2, ShouldNotBeNil) + + Convey("Can get first user token", func() { + token, err := userAuthTokenService.GetUserToken(userID, userToken.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.Id, ShouldEqual, userToken.Id) + }) + + Convey("Can get second user token", func() { + token, err := userAuthTokenService.GetUserToken(userID, userToken2.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.Id, ShouldEqual, userToken2.Id) + }) + + Convey("Can get user tokens", func() { + tokens, err := userAuthTokenService.GetUserTokens(userID) + So(err, ShouldBeNil) + So(tokens, ShouldHaveLength, 2) + So(tokens[0].Id, ShouldEqual, userToken.Id) + So(tokens[1].Id, ShouldEqual, userToken2.Id) + }) + + Convey("Can revoke all user tokens", func() { + err := userAuthTokenService.RevokeAllUserTokens(userID) + So(err, ShouldBeNil) + + model, err := ctx.getAuthTokenByID(userToken.Id) + So(err, ShouldBeNil) + So(model, ShouldBeNil) + + model2, err := ctx.getAuthTokenByID(userToken2.Id) + So(err, ShouldBeNil) + So(model2, ShouldBeNil) + }) + }) }) Convey("expires correctly", func() { diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/testing.go new file mode 100644 index 00000000000..68e65466c3d --- /dev/null +++ b/pkg/services/auth/testing.go @@ -0,0 +1,81 @@ +package auth + +import "github.com/grafana/grafana/pkg/models" + +type FakeUserAuthTokenService struct { + CreateTokenProvider func(userId int64, clientIP, userAgent string) (*models.UserToken, error) + TryRotateTokenProvider func(token *models.UserToken, clientIP, userAgent string) (bool, error) + LookupTokenProvider func(unhashedToken string) (*models.UserToken, error) + RevokeTokenProvider func(token *models.UserToken) error + RevokeAllUserTokensProvider func(userId int64) error + ActiveAuthTokenCount func() (int64, error) + GetUserTokenProvider func(userId, userTokenId int64) (*models.UserToken, error) + GetUserTokensProvider func(userId int64) ([]*models.UserToken, error) +} + +func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { + return &FakeUserAuthTokenService{ + CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + }, + TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + }, + RevokeTokenProvider: func(token *models.UserToken) error { + return nil + }, + RevokeAllUserTokensProvider: func(userId int64) error { + return nil + }, + ActiveAuthTokenCount: func() (int64, error) { + return 10, nil + }, + GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) { + return nil, nil + }, + GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) { + return nil, nil + }, + } +} + +func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { + return s.CreateTokenProvider(userId, clientIP, userAgent) +} + +func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) { + return s.LookupTokenProvider(unhashedToken) +} + +func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) { + return s.TryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error { + return s.RevokeTokenProvider(token) +} + +func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error { + return s.RevokeAllUserTokensProvider(userId) +} + +func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) { + return s.ActiveAuthTokenCount() +} + +func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) { + return s.GetUserTokenProvider(userId, userTokenId) +} + +func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) { + return s.GetUserTokensProvider(userId) +} diff --git a/pkg/services/sqlstore/migrations/cache_data_mig.go b/pkg/services/sqlstore/migrations/cache_data_mig.go new file mode 100644 index 00000000000..3467b88962b --- /dev/null +++ b/pkg/services/sqlstore/migrations/cache_data_mig.go @@ -0,0 +1,22 @@ +package migrations + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addCacheMigration(mg *migrator.Migrator) { + var cacheDataV1 = migrator.Table{ + Name: "cache_data", + Columns: []*migrator.Column{ + {Name: "cache_key", Type: migrator.DB_NVarchar, IsPrimaryKey: true, Length: 168}, + {Name: "data", Type: migrator.DB_Blob}, + {Name: "expires", Type: migrator.DB_Integer, Length: 255, Nullable: false}, + {Name: "created_at", Type: migrator.DB_Integer, Length: 255, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"cache_key"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create cache_data table", migrator.NewAddTableMigration(cacheDataV1)) + + mg.AddMigration("add unique index cache_data.cache_key", migrator.NewAddIndexMigration(cacheDataV1, cacheDataV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 931259ec3ed..3e40c749f37 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -33,6 +33,7 @@ func AddMigrations(mg *Migrator) { addUserAuthMigrations(mg) addServerlockMigrations(mg) addUserAuthTokenMigrations(mg) + addCacheMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 7b6e99255aa..bc57291b5f9 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -241,6 +241,12 @@ type Cfg struct { // User EditorsCanOwn bool + + // Dataproxy + SendUserHeader bool + + // DistributedCache + RemoteCacheOptions *RemoteCacheOptions } type CommandLineArgs struct { @@ -601,6 +607,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { dataproxy := iniFile.Section("dataproxy") DataProxyLogging = dataproxy.Key("logging").MustBool(false) DataProxyTimeout = dataproxy.Key("timeout").MustInt(30) + cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false) // read security settings security := iniFile.Section("security") @@ -781,9 +788,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { enterprise := iniFile.Section("enterprise") cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt")) + cacheServer := iniFile.Section("remote_cache") + cfg.RemoteCacheOptions = &RemoteCacheOptions{ + Name: cacheServer.Key("type").MustString("database"), + ConnStr: cacheServer.Key("connstr").MustString(""), + } + return nil } +type RemoteCacheOptions struct { + Name string + ConnStr string +} + func (cfg *Cfg) readSessionConfig() { sec := cfg.Raw.Section("session") SessionOptions = session.Options{} diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 12f2b6c03c9..c740d6cbe77 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -3,6 +3,7 @@ package mssql import ( "database/sql" "fmt" + "github.com/grafana/grafana/pkg/setting" "strconv" _ "github.com/denisenkom/go-mssqldb" @@ -24,7 +25,9 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin if err != nil { return nil, err } - logger.Debug("getEngine", "connection", cnnstr) + if setting.Env == setting.DEV { + logger.Debug("getEngine", "connection", cnnstr) + } config := tsdb.SqlQueryEndpointConfiguration{ DriverName: "mssql", diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index d307e12166c..0451f8f0dc1 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -3,6 +3,7 @@ package mysql import ( "database/sql" "fmt" + "github.com/grafana/grafana/pkg/setting" "reflect" "strconv" "strings" @@ -44,7 +45,9 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin cnnstr += "&tls=" + tlsConfigString } - logger.Debug("getEngine", "connection", cnnstr) + if setting.Env == setting.DEV { + logger.Debug("getEngine", "connection", cnnstr) + } config := tsdb.SqlQueryEndpointConfiguration{ DriverName: "mysql", diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index 4bcf06638f4..ae6b165e731 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -2,6 +2,7 @@ package postgres import ( "database/sql" + "github.com/grafana/grafana/pkg/setting" "net/url" "strconv" @@ -19,7 +20,9 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp logger := log.New("tsdb.postgres") cnnstr := generateConnectionString(datasource) - logger.Debug("getEngine", "connection", cnnstr) + if setting.Env == setting.DEV { + logger.Debug("getEngine", "connection", cnnstr) + } config := tsdb.SqlQueryEndpointConfiguration{ DriverName: "postgres", diff --git a/public/app/app.ts b/public/app/app.ts index 03f332e357b..a54c9270e2c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -17,12 +17,13 @@ import 'vendor/angular-other/angular-strap'; import $ from 'jquery'; import angular from 'angular'; import config from 'app/core/config'; +// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move import _ from 'lodash'; import moment from 'moment'; import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar'; // add move to lodash for backward compatabiltiy -_.move = (array, fromIndex, toIndex) => { +_.move = (array: [], fromIndex: number, toIndex: number) => { array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); return array; }; @@ -36,7 +37,7 @@ import 'app/features/all'; // import symlinked extensions const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); -extensionsIndex.keys().forEach(key => { +extensionsIndex.keys().forEach((key: any) => { extensionsIndex(key); }); @@ -52,7 +53,7 @@ export class GrafanaApp { this.ngModuleDependencies = []; } - useModule(module) { + useModule(module: angular.IModule) { if (this.preBootModules) { this.preBootModules.push(module); } else { @@ -67,40 +68,49 @@ export class GrafanaApp { moment.locale(config.bootData.user.locale); - app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => { - // pre assing bindings before constructor calls - $compileProvider.preAssignBindingsEnabled(true); + app.config( + ( + $locationProvider: angular.ILocationProvider, + $controllerProvider: angular.IControllerProvider, + $compileProvider: angular.ICompileProvider, + $filterProvider: angular.IFilterProvider, + $httpProvider: angular.IHttpProvider, + $provide: angular.auto.IProvideService + ) => { + // pre assing bindings before constructor calls + $compileProvider.preAssignBindingsEnabled(true); - if (config.buildInfo.env !== 'development') { - $compileProvider.debugInfoEnabled(false); - } + if (config.buildInfo.env !== 'development') { + $compileProvider.debugInfoEnabled(false); + } - $httpProvider.useApplyAsync(true); + $httpProvider.useApplyAsync(true); - this.registerFunctions.controller = $controllerProvider.register; - this.registerFunctions.directive = $compileProvider.directive; - this.registerFunctions.factory = $provide.factory; - this.registerFunctions.service = $provide.service; - this.registerFunctions.filter = $filterProvider.register; + this.registerFunctions.controller = $controllerProvider.register; + this.registerFunctions.directive = $compileProvider.directive; + this.registerFunctions.factory = $provide.factory; + this.registerFunctions.service = $provide.service; + this.registerFunctions.filter = $filterProvider.register; - $provide.decorator('$http', [ - '$delegate', - '$templateCache', - ($delegate, $templateCache) => { - const get = $delegate.get; - $delegate.get = (url, config) => { - if (url.match(/\.html$/)) { - // some template's already exist in the cache - if (!$templateCache.get(url)) { - url += '?v=' + new Date().getTime(); + $provide.decorator('$http', [ + '$delegate', + '$templateCache', + ($delegate: any, $templateCache: any) => { + const get = $delegate.get; + $delegate.get = (url: string, config: any) => { + if (url.match(/\.html$/)) { + // some template's already exist in the cache + if (!$templateCache.get(url)) { + url += '?v=' + new Date().getTime(); + } } - } - return get(url, config); - }; - return $delegate; - }, - ]); - }); + return get(url, config); + }; + return $delegate; + }, + ]); + } + ); this.ngModuleDependencies = [ 'grafana.core', @@ -116,7 +126,7 @@ export class GrafanaApp { ]; // makes it possible to add dynamic stuff - _.each(angularModules, m => { + _.each(angularModules, (m: angular.IModule) => { this.useModule(m); }); @@ -129,7 +139,7 @@ export class GrafanaApp { // bootstrap the app angular.bootstrap(document, this.ngModuleDependencies).invoke(() => { - _.each(this.preBootModules, module => { + _.each(this.preBootModules, (module: angular.IModule) => { _.extend(module, this.registerFunctions); }); diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index 25a69b1f5e4..3f6dacd311d 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -1,7 +1,30 @@ +// @ts-ignore import _ from 'lodash'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; import { SearchSrv } from 'app/core/services/search_srv'; +import { BackendSrv } from 'app/core/services/backend_srv'; +import { NavModelSrv } from 'app/core/nav_model_srv'; +import { ContextSrv } from 'app/core/services/context_srv'; + +export interface Section { + id: number; + uid: string; + title: string; + expanded: false; + items: any[]; + url: string; + icon: string; + score: number; + checked: boolean; + hideHeader: boolean; + toggle: Function; +} + +export interface FoldersAndDashboardUids { + folderUids: string[]; + dashboardUids: string[]; +} class Query { query: string; @@ -14,7 +37,7 @@ class Query { } export class ManageDashboardsCtrl { - sections: any[]; + sections: Section[]; query: Query; navModel: any; @@ -45,7 +68,12 @@ export class ManageDashboardsCtrl { hasEditPermissionInFolders: boolean; /** @ngInject */ - constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) { + constructor( + private backendSrv: BackendSrv, + navModelSrv: NavModelSrv, + private searchSrv: SearchSrv, + private contextSrv: ContextSrv + ) { this.isEditor = this.contextSrv.isEditor; this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders; @@ -73,7 +101,7 @@ export class ManageDashboardsCtrl { refreshList() { return this.searchSrv .search(this.query) - .then(result => { + .then((result: Section[]) => { return this.initDashboardList(result); }) .then(() => { @@ -81,7 +109,7 @@ export class ManageDashboardsCtrl { return; } - return this.backendSrv.getFolderByUid(this.folderUid).then(folder => { + return this.backendSrv.getFolderByUid(this.folderUid).then((folder: any) => { this.canSave = folder.canSave; if (!this.canSave) { this.hasEditPermissionInFolders = false; @@ -90,7 +118,7 @@ export class ManageDashboardsCtrl { }); } - initDashboardList(result: any) { + initDashboardList(result: Section[]) { this.canMove = false; this.canDelete = false; this.selectAllChecked = false; @@ -128,25 +156,25 @@ export class ManageDashboardsCtrl { this.canDelete = selectedDashboards > 0 || selectedFolders > 0; } - getFoldersAndDashboardsToDelete() { - const selectedDashboards = { - folders: [], - dashboards: [], + getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids { + const selectedDashboards: FoldersAndDashboardUids = { + folderUids: [], + dashboardUids: [], }; for (const section of this.sections) { if (section.checked && section.id !== 0) { - selectedDashboards.folders.push(section.uid); + selectedDashboards.folderUids.push(section.uid); } else { const selected = _.filter(section.items, { checked: true }); - selectedDashboards.dashboards.push(..._.map(selected, 'uid')); + selectedDashboards.dashboardUids.push(..._.map(selected, 'uid')); } } return selectedDashboards; } - getFolderIds(sections) { + getFolderIds(sections: Section[]) { const ids = []; for (const s of sections) { if (s.checked) { @@ -158,8 +186,8 @@ export class ManageDashboardsCtrl { delete() { const data = this.getFoldersAndDashboardsToDelete(); - const folderCount = data.folders.length; - const dashCount = data.dashboards.length; + const folderCount = data.folderUids.length; + const dashCount = data.dashboardUids.length; let text = 'Do you want to delete the '; let text2; @@ -179,12 +207,12 @@ export class ManageDashboardsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - this.deleteFoldersAndDashboards(data.folders, data.dashboards); + this.deleteFoldersAndDashboards(data.folderUids, data.dashboardUids); }, }); } - private deleteFoldersAndDashboards(folderUids, dashboardUids) { + private deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) { this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => { this.refreshList(); }); @@ -219,13 +247,13 @@ export class ManageDashboardsCtrl { } initTagFilter() { - return this.searchSrv.getDashboardTags().then(results => { + return this.searchSrv.getDashboardTags().then((results: any) => { this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results); this.selectedTagFilter = this.tagFilterOptions[0]; }); } - filterByTag(tag) { + filterByTag(tag: any) { if (_.indexOf(this.query.tag, tag) === -1) { this.query.tag.push(tag); } @@ -243,7 +271,7 @@ export class ManageDashboardsCtrl { return res; } - removeTag(tag, evt) { + removeTag(tag: any, evt: Event) { this.query.tag = _.without(this.query.tag, tag); this.refreshList(); if (evt) { @@ -269,7 +297,7 @@ export class ManageDashboardsCtrl { section.checked = this.selectAllChecked; } - section.items = _.map(section.items, item => { + section.items = _.map(section.items, (item: any) => { item.checked = this.selectAllChecked; return item; }); diff --git a/public/app/core/specs/manage_dashboards.test.ts b/public/app/core/specs/manage_dashboards.test.ts index 5af0ebade02..ef5e240fd36 100644 --- a/public/app/core/specs/manage_dashboards.test.ts +++ b/public/app/core/specs/manage_dashboards.test.ts @@ -1,12 +1,39 @@ -import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards'; -import { SearchSrv } from 'app/core/services/search_srv'; +// @ts-ignore import q from 'q'; +import { + ManageDashboardsCtrl, + Section, + FoldersAndDashboardUids, +} from 'app/core/components/manage_dashboards/manage_dashboards'; +import { SearchSrv } from 'app/core/services/search_srv'; +import { BackendSrv } from '../services/backend_srv'; +import { NavModelSrv } from '../nav_model_srv'; +import { ContextSrv } from '../services/context_srv'; + +const mockSection = (overides?: object): Section => { + const defaultSection: Section = { + id: 0, + items: [], + checked: false, + expanded: false, + hideHeader: false, + icon: '', + score: 0, + title: 'Some Section', + toggle: jest.fn(), + uid: 'someuid', + url: '/some/url/', + }; + + return { ...defaultSection, ...overides }; +}; describe('ManageDashboards', () => { - let ctrl; + let ctrl: ManageDashboardsCtrl; describe('when browsing dashboards', () => { beforeEach(() => { + const tags: any[] = []; const response = [ { id: 410, @@ -18,11 +45,11 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, }, ], - tags: [], + tags, isStarred: false, }, { @@ -37,11 +64,11 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, }, ], - tags: [], + tags, isStarred: false, }, ]; @@ -61,6 +88,7 @@ describe('ManageDashboards', () => { describe('when browsing dashboards for a folder', () => { beforeEach(() => { + const tags: any[] = []; const response = [ { id: 410, @@ -72,11 +100,11 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, }, ], - tags: [], + tags, isStarred: false, }, ]; @@ -92,6 +120,7 @@ describe('ManageDashboards', () => { describe('when searching dashboards', () => { beforeEach(() => { + const tags: any[] = []; const response = [ { checked: false, @@ -103,7 +132,7 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, folderId: 410, folderUid: 'uid', @@ -115,7 +144,7 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, folderId: 499, isStarred: false, }, @@ -245,7 +274,7 @@ describe('ManageDashboards', () => { }); describe('when selecting dashboards', () => { - let ctrl; + let ctrl: ManageDashboardsCtrl; beforeEach(() => { ctrl = createCtrlWithStubs([]); @@ -254,16 +283,16 @@ describe('ManageDashboards', () => { describe('and no dashboards are selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, items: [{ id: 2, checked: false }], checked: false, - }, - { + }), + mockSection({ id: 0, items: [{ id: 3, checked: false }], checked: false, - }, + }), ]; ctrl.selectionChanged(); }); @@ -302,16 +331,16 @@ describe('ManageDashboards', () => { describe('and all folders and dashboards are selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, items: [{ id: 2, checked: true }], checked: true, - }, - { + }), + mockSection({ id: 0, items: [{ id: 3, checked: true }], checked: true, - }, + }), ]; ctrl.selectionChanged(); }); @@ -350,18 +379,18 @@ describe('ManageDashboards', () => { describe('and one dashboard in root is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: false }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true }], checked: false, - }, + }), ]; ctrl.selectionChanged(); }); @@ -378,18 +407,18 @@ describe('ManageDashboards', () => { describe('and one child dashboard is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: true }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: false }], checked: false, - }, + }), ]; ctrl.selectionChanged(); @@ -407,18 +436,18 @@ describe('ManageDashboards', () => { describe('and one child dashboard and one dashboard is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: true }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true }], checked: false, - }, + }), ]; ctrl.selectionChanged(); @@ -436,24 +465,24 @@ describe('ManageDashboards', () => { describe('and one child dashboard and one folder is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: false }], checked: true, - }, - { + }), + mockSection({ id: 3, title: 'folder', items: [{ id: 4, checked: true }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: false }], checked: false, - }, + }), ]; ctrl.selectionChanged(); @@ -470,55 +499,55 @@ describe('ManageDashboards', () => { }); describe('when deleting dashboards', () => { - let toBeDeleted: any; + let toBeDeleted: FoldersAndDashboardUids; beforeEach(() => { ctrl = createCtrlWithStubs([]); ctrl.sections = [ - { + mockSection({ id: 1, uid: 'folder', title: 'folder', items: [{ id: 2, checked: true, uid: 'folder-dash' }], checked: true, - }, - { + }), + mockSection({ id: 3, title: 'folder-2', items: [{ id: 3, checked: true, uid: 'folder-2-dash' }], checked: false, uid: 'folder-2', - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true, uid: 'root-dash' }], checked: true, - }, + }), ]; toBeDeleted = ctrl.getFoldersAndDashboardsToDelete(); }); it('should return 1 folder', () => { - expect(toBeDeleted.folders.length).toEqual(1); + expect(toBeDeleted.folderUids.length).toEqual(1); }); it('should return 2 dashboards', () => { - expect(toBeDeleted.dashboards.length).toEqual(2); + expect(toBeDeleted.dashboardUids.length).toEqual(2); }); it('should filter out children if parent is checked', () => { - expect(toBeDeleted.folders[0]).toEqual('folder'); + expect(toBeDeleted.folderUids[0]).toEqual('folder'); }); it('should not filter out children if parent not is checked', () => { - expect(toBeDeleted.dashboards[0]).toEqual('folder-2-dash'); + expect(toBeDeleted.dashboardUids[0]).toEqual('folder-2-dash'); }); it('should not filter out children if parent is checked and root', () => { - expect(toBeDeleted.dashboards[1]).toEqual('root-dash'); + expect(toBeDeleted.dashboardUids[1]).toEqual('root-dash'); }); }); @@ -527,19 +556,19 @@ describe('ManageDashboards', () => { ctrl = createCtrlWithStubs([]); ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: true, uid: 'dash' }], checked: false, uid: 'folder', - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true, uid: 'dash-2' }], checked: false, - }, + }), ]; }); @@ -562,5 +591,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) { }, }; - return new ManageDashboardsCtrl({}, { getNav: () => {} }, searchSrvStub as SearchSrv, { isEditor: true }); + return new ManageDashboardsCtrl( + {} as BackendSrv, + { getNav: () => {} } as NavModelSrv, + searchSrvStub as SearchSrv, + { isEditor: true } as ContextSrv + ); } diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 3955f45d926..029fb0e8371 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -14,6 +14,7 @@ import { PanelEditor } from '../panel_editor/PanelEditor'; import { PanelModel, DashboardModel } from '../state'; import { PanelPlugin } from 'app/types'; import { PanelResizer } from './PanelResizer'; +import { PanelTypeChangedHook } from '@grafana/ui'; export interface Props { panel: PanelModel; @@ -91,7 +92,11 @@ export class DashboardPanel extends PureComponent { this.props.panel.changeType(pluginId); } 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); } } diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 88065fdf208..128bd8d0785 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -3,7 +3,7 @@ import _ from 'lodash'; // Types 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'; 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 oldPluginId = this.type; @@ -255,9 +255,12 @@ export class PanelModel { this.cachedPluginOptions[oldPluginId] = oldOptions; this.restorePanelOptions(pluginId); - if (preserveOptions && oldOptions) { + // Callback that can validate and migrate any existing settings + if (hook) { 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)); } } diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx index 4946a6a505d..bbf338df8f9 100644 --- a/public/app/features/explore/Table.tsx +++ b/public/app/features/explore/Table.tsx @@ -40,11 +40,15 @@ export default class Table extends PureComponent { const tableModel = data || EMPTY_TABLE; const columnNames = tableModel.columns.map(({ text }) => text); const columns = tableModel.columns.map(({ filterable, text }) => ({ - Header: text, + Header: () => {text}, accessor: text, className: VALUE_REGEX.test(text) ? 'text-right' : '', show: text !== 'Time', - Cell: row => {row.value}, + Cell: row => ( + + {row.value} + + ), })); const noDataText = data ? 'The queries returned no data for a table.' : ''; diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx index d5dcbabc34f..e7f2e2d7738 100644 --- a/public/app/plugins/panel/bargauge/module.tsx +++ b/public/app/plugins/panel/bargauge/module.tsx @@ -8,10 +8,8 @@ export const reactPanel = new ReactPanelPlugin(BarGaugePanel); reactPanel.setEditor(BarGaugePanelEditor); reactPanel.setDefaults(defaults); -reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => { - const options: Partial = {}; - - if (prevOptions.valueOptions) { +reactPanel.setPanelTypeChangedHook((options: BarGaugeOptions, prevPluginId?: string, prevOptions?: any) => { + if (prevOptions && prevOptions.valueOptions) { options.valueOptions = prevOptions.valueOptions; options.thresholds = prevOptions.thresholds; options.maxValue = prevOptions.maxValue; diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index 95a6e29ae4d..b8e90a27bff 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -8,10 +8,8 @@ export const reactPanel = new ReactPanelPlugin(GaugePanel); reactPanel.setEditor(GaugePanelEditor); reactPanel.setDefaults(defaults); -reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => { - const options: Partial = {}; - - if (prevOptions.valueOptions) { +reactPanel.setPanelTypeChangedHook((options: GaugeOptions, prevPluginId?: string, prevOptions?: any) => { + if (prevOptions && prevOptions.valueOptions) { options.valueOptions = prevOptions.valueOptions; options.thresholds = prevOptions.thresholds; options.maxValue = prevOptions.maxValue; diff --git a/public/app/plugins/panel/text2/module.tsx b/public/app/plugins/panel/text2/module.tsx index bac292e8c29..b2e3057de34 100644 --- a/public/app/plugins/panel/text2/module.tsx +++ b/public/app/plugins/panel/text2/module.tsx @@ -8,3 +8,10 @@ export const reactPanel = new ReactPanelPlugin(TextPanel); reactPanel.setEditor(TextPanelEditor); reactPanel.setDefaults(defaults); +reactPanel.setPanelTypeChangedHook((options: TextOptions, prevPluginId: string, prevOptions: any) => { + if (prevPluginId === 'text') { + return prevOptions as TextOptions; + } + + return options; +}); diff --git a/scripts/circle-test-cache-servers.sh b/scripts/circle-test-cache-servers.sh new file mode 100755 index 00000000000..bacd9928362 --- /dev/null +++ b/scripts/circle-test-cache-servers.sh @@ -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/...