mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'grafana/master' into all-data-as-table
* grafana/master: (57 commits) feature(explore/table): Add tooltips to explore table (#16007) Update changelog Add check for Env before log Update index.md chore: Cleaning up implicit anys in manage_dashboard.ts and manage_dashboard.test.ts progress: #14714 chore: Cleaning up implicit anys in app.ts progress: #14714 changelog: adds note about closing #15836 changelog: adds note about closing #6359 and #15931 add partial Add check so that header is not sent for anonymous users Update config docs Add custom header with grafana user and a config switch for it changelog: adds note about closing #10816 use constants for cache type makes variables template prettier complient white space formating changelog: adds note about #15744 updates old distcache names dont allow inifinite expiration return error if cache type is invalid ...
This commit is contained in:
commit
ff3c25ad79
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -102,6 +102,17 @@ log_queries =
|
||||
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
||||
;cache_mode = private
|
||||
|
||||
#################################### Cache server #############################
|
||||
[remote_cache]
|
||||
# Either "redis", "memcached" or "database" default is "database"
|
||||
;type = database
|
||||
|
||||
# cache connectionstring options
|
||||
# database: will use Grafana primary database.
|
||||
# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
|
||||
# memcache: 127.0.0.1:11211
|
||||
;connstr =
|
||||
|
||||
#################################### Session ####################################
|
||||
[session]
|
||||
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
|
||||
@ -133,6 +144,9 @@ log_queries =
|
||||
# How long the data proxy should wait before timing out default is 30 (seconds)
|
||||
;timeout = 30
|
||||
|
||||
# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
|
||||
;send_user_header = false
|
||||
|
||||
#################################### Analytics ####################################
|
||||
[analytics]
|
||||
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
|
||||
|
@ -1,4 +1,4 @@
|
||||
memcached:
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## Templated queries
|
||||
|
||||
Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place.
|
||||
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
@ -179,7 +179,6 @@ Path to the certificate key file (if `protocol` is set to `https`).
|
||||
|
||||
Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events
|
||||
to grafana log.
|
||||
<hr />
|
||||
|
||||
<hr />
|
||||
|
||||
@ -262,6 +261,19 @@ Set to `true` to log the sql calls and execution times.
|
||||
For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
|
||||
Defaults to private.
|
||||
|
||||
<hr />
|
||||
|
||||
## [remote_cache]
|
||||
|
||||
### type
|
||||
|
||||
Either `redis`, `memcached` or `database` default is `database`
|
||||
|
||||
### connstr
|
||||
|
||||
The remote cache connection string. Leave empty when using `database` since it will use the primary database.
|
||||
Redis example config: `addr=127.0.0.1:6379,pool_size=100,db=grafana`
|
||||
Memcache example: `127.0.0.1:11211`
|
||||
|
||||
<hr />
|
||||
|
||||
@ -399,6 +411,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
|
||||
|
||||
<hr />
|
||||
|
||||
## [dataproxy]
|
||||
|
||||
### logging
|
||||
|
||||
This enables data proxy logging, default is false.
|
||||
|
||||
### timeout
|
||||
|
||||
How long the data proxy should wait before timing out default is 30 (seconds)
|
||||
|
||||
### send_user_header
|
||||
|
||||
If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
|
||||
|
||||
<hr />
|
||||
|
||||
## [analytics]
|
||||
|
||||
### reporting_enabled
|
||||
|
@ -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"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -21,13 +21,21 @@ export interface PanelEditorProps<T = any> {
|
||||
onOptionsChange: (options: T) => void;
|
||||
}
|
||||
|
||||
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
|
||||
/**
|
||||
* Called before a panel is initalized
|
||||
*/
|
||||
export type PanelTypeChangedHook<TOptions = any> = (
|
||||
options: Partial<TOptions>,
|
||||
prevPluginId?: string,
|
||||
prevOptions?: any
|
||||
) => Partial<TOptions>;
|
||||
|
||||
export class ReactPanelPlugin<TOptions = any> {
|
||||
panel: ComponentClass<PanelProps<TOptions>>;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
defaults?: TOptions;
|
||||
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
|
||||
|
||||
panelTypeChangedHook?: PanelTypeChangedHook<TOptions>;
|
||||
|
||||
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
|
||||
this.panel = panel;
|
||||
@ -41,8 +49,12 @@ export class ReactPanelPlugin<TOptions = any> {
|
||||
this.defaults = defaults;
|
||||
}
|
||||
|
||||
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
|
||||
this.preserveOptions = handler;
|
||||
/**
|
||||
* Called when the visualization changes.
|
||||
* Lets you keep whatever settings made sense in the previous panel
|
||||
*/
|
||||
setPanelTypeChangedHook(v: PanelTypeChangedHook<TOptions>) {
|
||||
this.panelTypeChangedHook = v;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
@ -101,6 +102,7 @@ type scenarioContext struct {
|
||||
defaultHandler macaron.Handler
|
||||
req *http.Request
|
||||
url string
|
||||
userAuthTokenService *auth.FakeUserAuthTokenService
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) exec() {
|
||||
|
@ -31,7 +31,7 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
|
||||
// macaron does not include trailing slashes when resolving a wildcard path
|
||||
proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
|
||||
|
||||
proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath)
|
||||
proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath, hs.Cfg)
|
||||
proxy.HandleRequest()
|
||||
}
|
||||
|
||||
|
12
pkg/api/dtos/user_token.go
Normal file
12
pkg/api/dtos/user_token.go
Normal file
@ -0,0 +1,12 @@
|
||||
package dtos
|
||||
|
||||
import "time"
|
||||
|
||||
type UserToken struct {
|
||||
Id int64 `json:"id"`
|
||||
IsActive bool `json:"isActive"`
|
||||
ClientIp string `json:"clientIp"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
SeenAt time.Time `json:"seenAt"`
|
||||
}
|
@ -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 {
|
||||
|
@ -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,7 +388,66 @@ 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 {
|
||||
|
@ -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 {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package pluginproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@ -44,4 +45,59 @@ func TestPluginProxy(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When SendUserHeader config is enabled", t, func() {
|
||||
req := getPluginProxiedRequest(
|
||||
&m.ReqContext{
|
||||
SignedInUser: &m.SignedInUser{
|
||||
Login: "test_user",
|
||||
},
|
||||
},
|
||||
&setting.Cfg{SendUserHeader: true},
|
||||
)
|
||||
|
||||
Convey("Should add header with username", func() {
|
||||
// Get will return empty string even if header is not set
|
||||
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When SendUserHeader config is disabled", t, func() {
|
||||
req := getPluginProxiedRequest(
|
||||
&m.ReqContext{
|
||||
SignedInUser: &m.SignedInUser{
|
||||
Login: "test_user",
|
||||
},
|
||||
},
|
||||
&setting.Cfg{SendUserHeader: false},
|
||||
)
|
||||
Convey("Should not add header with username", func() {
|
||||
// Get will return empty string even if header is not set
|
||||
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When SendUserHeader config is enabled but user is anonymous", t, func() {
|
||||
req := getPluginProxiedRequest(
|
||||
&m.ReqContext{
|
||||
SignedInUser: &m.SignedInUser{IsAnonymous: true},
|
||||
},
|
||||
&setting.Cfg{SendUserHeader: true},
|
||||
)
|
||||
|
||||
Convey("Should not add header with username", func() {
|
||||
// Get will return empty string even if header is not set
|
||||
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
|
||||
func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
|
||||
route := &plugins.AppPluginRoute{}
|
||||
proxy := NewApiPluginProxy(ctx, "", route, "", cfg)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
|
||||
So(err, ShouldBeNil)
|
||||
proxy.Director(req)
|
||||
return req
|
||||
}
|
||||
|
110
pkg/api/user_token.go
Normal file
110
pkg/api/user_token.go
Normal file
@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
// GET /api/user/auth-tokens
|
||||
func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response {
|
||||
return server.getUserAuthTokensInternal(c, c.UserId)
|
||||
}
|
||||
|
||||
// POST /api/user/revoke-auth-token
|
||||
func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response {
|
||||
return server.revokeUserAuthTokenInternal(c, c.UserId, cmd)
|
||||
}
|
||||
|
||||
func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response {
|
||||
userQuery := models.GetUserByIdQuery{Id: userID}
|
||||
|
||||
if err := bus.Dispatch(&userQuery); err != nil {
|
||||
if err == models.ErrUserNotFound {
|
||||
return Error(404, "User not found", err)
|
||||
}
|
||||
return Error(500, "Could not read user from database", err)
|
||||
}
|
||||
|
||||
err := server.AuthTokenService.RevokeAllUserTokens(userID)
|
||||
if err != nil {
|
||||
return Error(500, "Failed to logout user", err)
|
||||
}
|
||||
|
||||
return JSON(200, util.DynMap{
|
||||
"message": "User logged out",
|
||||
})
|
||||
}
|
||||
|
||||
func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response {
|
||||
userQuery := models.GetUserByIdQuery{Id: userID}
|
||||
|
||||
if err := bus.Dispatch(&userQuery); err != nil {
|
||||
if err == models.ErrUserNotFound {
|
||||
return Error(404, "User not found", err)
|
||||
}
|
||||
return Error(500, "Failed to get user", err)
|
||||
}
|
||||
|
||||
tokens, err := server.AuthTokenService.GetUserTokens(userID)
|
||||
if err != nil {
|
||||
return Error(500, "Failed to get user auth tokens", err)
|
||||
}
|
||||
|
||||
result := []*dtos.UserToken{}
|
||||
for _, token := range tokens {
|
||||
isActive := false
|
||||
if c.UserToken != nil && c.UserToken.Id == token.Id {
|
||||
isActive = true
|
||||
}
|
||||
|
||||
result = append(result, &dtos.UserToken{
|
||||
Id: token.Id,
|
||||
IsActive: isActive,
|
||||
ClientIp: token.ClientIp,
|
||||
UserAgent: token.UserAgent,
|
||||
CreatedAt: time.Unix(token.CreatedAt, 0),
|
||||
SeenAt: time.Unix(token.SeenAt, 0),
|
||||
})
|
||||
}
|
||||
|
||||
return JSON(200, result)
|
||||
}
|
||||
|
||||
func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response {
|
||||
userQuery := models.GetUserByIdQuery{Id: userID}
|
||||
|
||||
if err := bus.Dispatch(&userQuery); err != nil {
|
||||
if err == models.ErrUserNotFound {
|
||||
return Error(404, "User not found", err)
|
||||
}
|
||||
return Error(500, "Failed to get user", err)
|
||||
}
|
||||
|
||||
token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId)
|
||||
if err != nil {
|
||||
if err == models.ErrUserTokenNotFound {
|
||||
return Error(404, "User auth token not found", err)
|
||||
}
|
||||
return Error(500, "Failed to get user auth token", err)
|
||||
}
|
||||
|
||||
if c.UserToken != nil && c.UserToken.Id == token.Id {
|
||||
return Error(400, "Cannot revoke active user auth token", nil)
|
||||
}
|
||||
|
||||
err = server.AuthTokenService.RevokeToken(token)
|
||||
if err != nil {
|
||||
if err == models.ErrUserTokenNotFound {
|
||||
return Error(404, "User auth token not found", err)
|
||||
}
|
||||
return Error(500, "Failed to revoke user auth token", err)
|
||||
}
|
||||
|
||||
return JSON(200, util.DynMap{
|
||||
"message": "User auth token revoked",
|
||||
})
|
||||
}
|
294
pkg/api/user_token_test.go
Normal file
294
pkg/api/user_token_test.go
Normal file
@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestUserTokenApiEndpoint(t *testing.T) {
|
||||
Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() {
|
||||
userId := int64(0)
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
userId = cmd.Id
|
||||
return m.ErrUserNotFound
|
||||
})
|
||||
|
||||
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
|
||||
revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
So(userId, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When current user gets auth tokens for a non-existing user", t, func() {
|
||||
userId := int64(0)
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
userId = cmd.Id
|
||||
return m.ErrUserNotFound
|
||||
})
|
||||
|
||||
getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
So(userId, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When logout an existing user from all devices", t, func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
cmd.Result = &m.User{Id: 200}
|
||||
return nil
|
||||
})
|
||||
|
||||
logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When logout a non-existing user from all devices", t, func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
return m.ErrUserNotFound
|
||||
})
|
||||
|
||||
logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When revoke an auth token for a user", t, func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
cmd.Result = &m.User{Id: 200}
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
token := &m.UserToken{Id: 1}
|
||||
|
||||
revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) {
|
||||
sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
|
||||
return &m.UserToken{Id: 2}, nil
|
||||
}
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When revoke the active auth token used by himself", t, func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
cmd.Result = &m.User{Id: TestUserID}
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
|
||||
token := &m.UserToken{Id: 2}
|
||||
|
||||
revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) {
|
||||
sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
|
||||
return token, nil
|
||||
}
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
So(sc.resp.Code, ShouldEqual, 400)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When gets auth tokens for a user", t, func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
|
||||
cmd.Result = &m.User{Id: TestUserID}
|
||||
return nil
|
||||
})
|
||||
|
||||
currentToken := &m.UserToken{Id: 1}
|
||||
|
||||
getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) {
|
||||
tokens := []*m.UserToken{
|
||||
{
|
||||
Id: 1,
|
||||
ClientIp: "127.0.0.1",
|
||||
UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
SeenAt: time.Now().Unix(),
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
ClientIp: "127.0.0.2",
|
||||
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
SeenAt: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) {
|
||||
return tokens, nil
|
||||
}
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
result := sc.ToJSON()
|
||||
So(result.MustArray(), ShouldHaveLength, 2)
|
||||
|
||||
resultOne := result.GetIndex(0)
|
||||
So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id)
|
||||
So(resultOne.Get("isActive").MustBool(), ShouldBeTrue)
|
||||
So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1")
|
||||
So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36")
|
||||
So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339))
|
||||
So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339))
|
||||
|
||||
resultTwo := result.GetIndex(1)
|
||||
So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id)
|
||||
So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse)
|
||||
So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2")
|
||||
So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1")
|
||||
So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
|
||||
So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) {
|
||||
Convey(desc+" "+url, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(url)
|
||||
sc.userAuthTokenService = fakeAuthTokenService
|
||||
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = userId
|
||||
sc.context.OrgId = TestOrgID
|
||||
sc.context.OrgRole = m.ROLE_ADMIN
|
||||
|
||||
return hs.RevokeUserAuthToken(c, cmd)
|
||||
})
|
||||
|
||||
sc.m.Post(routePattern, sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) {
|
||||
Convey(desc+" "+url, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(url)
|
||||
sc.userAuthTokenService = fakeAuthTokenService
|
||||
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = userId
|
||||
sc.context.OrgId = TestOrgID
|
||||
sc.context.OrgRole = m.ROLE_ADMIN
|
||||
|
||||
return hs.GetUserAuthTokens(c)
|
||||
})
|
||||
|
||||
sc.m.Get(routePattern, sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) {
|
||||
Convey(desc, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: auth.NewFakeUserAuthTokenService(),
|
||||
}
|
||||
|
||||
sc := setupScenarioContext("/")
|
||||
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = TestUserID
|
||||
sc.context.OrgId = TestOrgID
|
||||
sc.context.OrgRole = m.ROLE_ADMIN
|
||||
|
||||
return hs.logoutUserFromAllDevicesInternal(userId)
|
||||
})
|
||||
|
||||
sc.m.Post("/", sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) {
|
||||
Convey(desc, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext("/")
|
||||
sc.userAuthTokenService = fakeAuthTokenService
|
||||
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = TestUserID
|
||||
sc.context.OrgId = TestOrgID
|
||||
sc.context.OrgRole = m.ROLE_ADMIN
|
||||
sc.context.UserToken = token
|
||||
|
||||
return hs.revokeUserAuthTokenInternal(c, userId, cmd)
|
||||
})
|
||||
|
||||
sc.m.Post("/", sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) {
|
||||
Convey(desc, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
AuthTokenService: fakeAuthTokenService,
|
||||
}
|
||||
|
||||
sc := setupScenarioContext("/")
|
||||
sc.userAuthTokenService = fakeAuthTokenService
|
||||
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = TestUserID
|
||||
sc.context.OrgId = TestOrgID
|
||||
sc.context.OrgRole = m.ROLE_ADMIN
|
||||
sc.context.UserToken = token
|
||||
|
||||
return hs.getUserAuthTokensInternal(c, TestUserID)
|
||||
})
|
||||
|
||||
sc.m.Get("/", sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
@ -29,6 +29,7 @@ import (
|
||||
// self registering services
|
||||
_ "github.com/grafana/grafana/pkg/extensions"
|
||||
_ "github.com/grafana/grafana/pkg/infra/metrics"
|
||||
_ "github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
_ "github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
_ "github.com/grafana/grafana/pkg/infra/tracing"
|
||||
_ "github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
|
126
pkg/infra/remotecache/database_storage.go
Normal file
126
pkg/infra/remotecache/database_storage.go
Normal file
@ -0,0 +1,126 @@
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
var getTime = time.Now
|
||||
|
||||
const databaseCacheType = "database"
|
||||
|
||||
type databaseCache struct {
|
||||
SQLStore *sqlstore.SqlStore
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func newDatabaseCache(sqlstore *sqlstore.SqlStore) *databaseCache {
|
||||
dc := &databaseCache{
|
||||
SQLStore: sqlstore,
|
||||
log: log.New("remotecache.database"),
|
||||
}
|
||||
|
||||
return dc
|
||||
}
|
||||
|
||||
func (dc *databaseCache) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(time.Minute * 10)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
dc.internalRunGC()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *databaseCache) internalRunGC() {
|
||||
now := getTime().Unix()
|
||||
sql := `DELETE FROM cache_data WHERE (? - created_at) >= expires AND expires <> 0`
|
||||
|
||||
_, err := dc.SQLStore.NewSession().Exec(sql, now)
|
||||
if err != nil {
|
||||
dc.log.Error("failed to run garbage collect", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *databaseCache) Get(key string) (interface{}, error) {
|
||||
cacheHit := CacheData{}
|
||||
session := dc.SQLStore.NewSession()
|
||||
defer session.Close()
|
||||
|
||||
exist, err := session.Where("cache_key= ?", key).Get(&cacheHit)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !exist {
|
||||
return nil, ErrCacheItemNotFound
|
||||
}
|
||||
|
||||
if cacheHit.Expires > 0 {
|
||||
existedButExpired := getTime().Unix()-cacheHit.CreatedAt >= cacheHit.Expires
|
||||
if existedButExpired {
|
||||
_ = dc.Delete(key) //ignore this error since we will return `ErrCacheItemNotFound` anyway
|
||||
return nil, ErrCacheItemNotFound
|
||||
}
|
||||
}
|
||||
|
||||
item := &cachedItem{}
|
||||
if err = decodeGob(cacheHit.Data, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return item.Val, nil
|
||||
}
|
||||
|
||||
func (dc *databaseCache) Set(key string, value interface{}, expire time.Duration) error {
|
||||
item := &cachedItem{Val: value}
|
||||
data, err := encodeGob(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session := dc.SQLStore.NewSession()
|
||||
|
||||
var cacheHit CacheData
|
||||
has, err := session.Where("cache_key = ?", key).Get(&cacheHit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var expiresInSeconds int64
|
||||
if expire != 0 {
|
||||
expiresInSeconds = int64(expire) / int64(time.Second)
|
||||
}
|
||||
|
||||
// insert or update depending on if item already exist
|
||||
if has {
|
||||
sql := `UPDATE cache_data SET data=?, created=?, expire=? WHERE cache_key='?'`
|
||||
_, err = session.Exec(sql, data, getTime().Unix(), expiresInSeconds, key)
|
||||
} else {
|
||||
sql := `INSERT INTO cache_data (cache_key,data,created_at,expires) VALUES(?,?,?,?)`
|
||||
_, err = session.Exec(sql, key, data, getTime().Unix(), expiresInSeconds)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (dc *databaseCache) Delete(key string) error {
|
||||
sql := "DELETE FROM cache_data WHERE cache_key=?"
|
||||
_, err := dc.SQLStore.NewSession().Exec(sql, key)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type CacheData struct {
|
||||
CacheKey string
|
||||
Data []byte
|
||||
Expires int64
|
||||
CreatedAt int64
|
||||
}
|
56
pkg/infra/remotecache/database_storage_test.go
Normal file
56
pkg/infra/remotecache/database_storage_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bmizerany/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
func TestDatabaseStorageGarbageCollection(t *testing.T) {
|
||||
sqlstore := sqlstore.InitTestDB(t)
|
||||
|
||||
db := &databaseCache{
|
||||
SQLStore: sqlstore,
|
||||
log: log.New("remotecache.database"),
|
||||
}
|
||||
|
||||
obj := &CacheableStruct{String: "foolbar"}
|
||||
|
||||
//set time.now to 2 weeks ago
|
||||
var err error
|
||||
getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) }
|
||||
err = db.Set("key1", obj, 1000*time.Second)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
err = db.Set("key2", obj, 1000*time.Second)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
err = db.Set("key3", obj, 1000*time.Second)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
// insert object that should never expire
|
||||
db.Set("key4", obj, 0)
|
||||
|
||||
getTime = time.Now
|
||||
db.Set("key5", obj, 1000*time.Second)
|
||||
|
||||
//run GC
|
||||
db.internalRunGC()
|
||||
|
||||
//try to read values
|
||||
_, err = db.Get("key1")
|
||||
assert.Equal(t, err, ErrCacheItemNotFound, "expected cache item not found. got: ", err)
|
||||
_, err = db.Get("key2")
|
||||
assert.Equal(t, err, ErrCacheItemNotFound)
|
||||
_, err = db.Get("key3")
|
||||
assert.Equal(t, err, ErrCacheItemNotFound)
|
||||
|
||||
_, err = db.Get("key4")
|
||||
assert.Equal(t, err, nil)
|
||||
_, err = db.Get("key5")
|
||||
assert.Equal(t, err, nil)
|
||||
}
|
71
pkg/infra/remotecache/memcached_storage.go
Normal file
71
pkg/infra/remotecache/memcached_storage.go
Normal file
@ -0,0 +1,71 @@
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/bradfitz/gomemcache/memcache"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const memcachedCacheType = "memcached"
|
||||
|
||||
type memcachedStorage struct {
|
||||
c *memcache.Client
|
||||
}
|
||||
|
||||
func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage {
|
||||
return &memcachedStorage{
|
||||
c: memcache.New(opts.ConnStr),
|
||||
}
|
||||
}
|
||||
|
||||
func newItem(sid string, data []byte, expire int32) *memcache.Item {
|
||||
return &memcache.Item{
|
||||
Key: sid,
|
||||
Value: data,
|
||||
Expiration: expire,
|
||||
}
|
||||
}
|
||||
|
||||
// Set sets value to given key in the cache.
|
||||
func (s *memcachedStorage) Set(key string, val interface{}, expires time.Duration) error {
|
||||
item := &cachedItem{Val: val}
|
||||
bytes, err := encodeGob(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var expiresInSeconds int64
|
||||
if expires != 0 {
|
||||
expiresInSeconds = int64(expires) / int64(time.Second)
|
||||
}
|
||||
|
||||
memcachedItem := newItem(key, bytes, int32(expiresInSeconds))
|
||||
return s.c.Set(memcachedItem)
|
||||
}
|
||||
|
||||
// Get gets value by given key in the cache.
|
||||
func (s *memcachedStorage) Get(key string) (interface{}, error) {
|
||||
memcachedItem, err := s.c.Get(key)
|
||||
if err != nil && err.Error() == "memcache: cache miss" {
|
||||
return nil, ErrCacheItemNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item := &cachedItem{}
|
||||
|
||||
err = decodeGob(memcachedItem.Value, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return item.Val, nil
|
||||
}
|
||||
|
||||
// Delete delete a key from the cache
|
||||
func (s *memcachedStorage) Delete(key string) error {
|
||||
return s.c.Delete(key)
|
||||
}
|
15
pkg/infra/remotecache/memcached_storage_integration_test.go
Normal file
15
pkg/infra/remotecache/memcached_storage_integration_test.go
Normal file
@ -0,0 +1,15 @@
|
||||
// +build memcached
|
||||
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestMemcachedCacheStorage(t *testing.T) {
|
||||
opts := &setting.RemoteCacheOptions{Name: memcachedCacheType, ConnStr: "localhost:11211"}
|
||||
client := createTestClient(t, opts, nil)
|
||||
runTestsForClient(t, client)
|
||||
}
|
62
pkg/infra/remotecache/redis_storage.go
Normal file
62
pkg/infra/remotecache/redis_storage.go
Normal file
@ -0,0 +1,62 @@
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
redis "gopkg.in/redis.v2"
|
||||
)
|
||||
|
||||
const redisCacheType = "redis"
|
||||
|
||||
type redisStorage struct {
|
||||
c *redis.Client
|
||||
}
|
||||
|
||||
func newRedisStorage(opts *setting.RemoteCacheOptions) *redisStorage {
|
||||
opt := &redis.Options{
|
||||
Network: "tcp",
|
||||
Addr: opts.ConnStr,
|
||||
}
|
||||
return &redisStorage{c: redis.NewClient(opt)}
|
||||
}
|
||||
|
||||
// Set sets value to given key in session.
|
||||
func (s *redisStorage) Set(key string, val interface{}, expires time.Duration) error {
|
||||
item := &cachedItem{Val: val}
|
||||
value, err := encodeGob(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status := s.c.SetEx(key, expires, string(value))
|
||||
return status.Err()
|
||||
}
|
||||
|
||||
// Get gets value by given key in session.
|
||||
func (s *redisStorage) Get(key string) (interface{}, error) {
|
||||
v := s.c.Get(key)
|
||||
|
||||
item := &cachedItem{}
|
||||
err := decodeGob([]byte(v.Val()), item)
|
||||
|
||||
if err == nil {
|
||||
return item.Val, nil
|
||||
}
|
||||
|
||||
if err.Error() == "EOF" {
|
||||
return nil, ErrCacheItemNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return item.Val, nil
|
||||
}
|
||||
|
||||
// Delete delete a key from session.
|
||||
func (s *redisStorage) Delete(key string) error {
|
||||
cmd := s.c.Del(key)
|
||||
return cmd.Err()
|
||||
}
|
16
pkg/infra/remotecache/redis_storage_integration_test.go
Normal file
16
pkg/infra/remotecache/redis_storage_integration_test.go
Normal file
@ -0,0 +1,16 @@
|
||||
// +build redis
|
||||
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestRedisCacheStorage(t *testing.T) {
|
||||
|
||||
opts := &setting.RemoteCacheOptions{Name: redisCacheType, ConnStr: "localhost:6379"}
|
||||
client := createTestClient(t, opts, nil)
|
||||
runTestsForClient(t, client)
|
||||
}
|
133
pkg/infra/remotecache/remotecache.go
Normal file
133
pkg/infra/remotecache/remotecache.go
Normal file
@ -0,0 +1,133 @@
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCacheItemNotFound is returned if cache does not exist
|
||||
ErrCacheItemNotFound = errors.New("cache item not found")
|
||||
|
||||
// ErrInvalidCacheType is returned if the type is invalid
|
||||
ErrInvalidCacheType = errors.New("invalid remote cache name")
|
||||
|
||||
defaultMaxCacheExpiration = time.Hour * 24
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.RegisterService(&RemoteCache{})
|
||||
}
|
||||
|
||||
// CacheStorage allows the caller to set, get and delete items in the cache.
|
||||
// Cached items are stored as byte arrays and marshalled using "encoding/gob"
|
||||
// so any struct added to the cache needs to be registred with `remotecache.Register`
|
||||
// ex `remotecache.Register(CacheableStruct{})``
|
||||
type CacheStorage interface {
|
||||
// Get reads object from Cache
|
||||
Get(key string) (interface{}, error)
|
||||
|
||||
// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
|
||||
Set(key string, value interface{}, expire time.Duration) error
|
||||
|
||||
// Delete object from cache
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// RemoteCache allows Grafana to cache data outside its own process
|
||||
type RemoteCache struct {
|
||||
log log.Logger
|
||||
client CacheStorage
|
||||
SQLStore *sqlstore.SqlStore `inject:""`
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
}
|
||||
|
||||
// Get reads object from Cache
|
||||
func (ds *RemoteCache) Get(key string) (interface{}, error) {
|
||||
return ds.client.Get(key)
|
||||
}
|
||||
|
||||
// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
|
||||
func (ds *RemoteCache) Set(key string, value interface{}, expire time.Duration) error {
|
||||
if expire == 0 {
|
||||
expire = defaultMaxCacheExpiration
|
||||
}
|
||||
|
||||
return ds.client.Set(key, value, expire)
|
||||
}
|
||||
|
||||
// Delete object from cache
|
||||
func (ds *RemoteCache) Delete(key string) error {
|
||||
return ds.client.Delete(key)
|
||||
}
|
||||
|
||||
// Init initializes the service
|
||||
func (ds *RemoteCache) Init() error {
|
||||
ds.log = log.New("cache.remote")
|
||||
var err error
|
||||
ds.client, err = createClient(ds.Cfg.RemoteCacheOptions, ds.SQLStore)
|
||||
return err
|
||||
}
|
||||
|
||||
// Run start the backend processes for cache clients
|
||||
func (ds *RemoteCache) Run(ctx context.Context) error {
|
||||
//create new interface if more clients need GC jobs
|
||||
backgroundjob, ok := ds.client.(registry.BackgroundService)
|
||||
if ok {
|
||||
return backgroundjob.Run(ctx)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func createClient(opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) (CacheStorage, error) {
|
||||
if opts.Name == redisCacheType {
|
||||
return newRedisStorage(opts), nil
|
||||
}
|
||||
|
||||
if opts.Name == memcachedCacheType {
|
||||
return newMemcachedStorage(opts), nil
|
||||
}
|
||||
|
||||
if opts.Name == databaseCacheType {
|
||||
return newDatabaseCache(sqlstore), nil
|
||||
}
|
||||
|
||||
return nil, ErrInvalidCacheType
|
||||
}
|
||||
|
||||
// Register records a type, identified by a value for that type, under its
|
||||
// internal type name. That name will identify the concrete type of a value
|
||||
// sent or received as an interface variable. Only types that will be
|
||||
// transferred as implementations of interface values need to be registered.
|
||||
// Expecting to be used only during initialization, it panics if the mapping
|
||||
// between types and names is not a bijection.
|
||||
func Register(value interface{}) {
|
||||
gob.Register(value)
|
||||
}
|
||||
|
||||
type cachedItem struct {
|
||||
Val interface{}
|
||||
}
|
||||
|
||||
func encodeGob(item *cachedItem) ([]byte, error) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
err := gob.NewEncoder(buf).Encode(item)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func decodeGob(data []byte, out *cachedItem) error {
|
||||
buf := bytes.NewBuffer(data)
|
||||
return gob.NewDecoder(buf).Decode(&out)
|
||||
}
|
93
pkg/infra/remotecache/remotecache_test.go
Normal file
93
pkg/infra/remotecache/remotecache_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package remotecache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bmizerany/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type CacheableStruct struct {
|
||||
String string
|
||||
Int64 int64
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(CacheableStruct{})
|
||||
}
|
||||
|
||||
func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) CacheStorage {
|
||||
t.Helper()
|
||||
|
||||
dc := &RemoteCache{
|
||||
SQLStore: sqlstore,
|
||||
Cfg: &setting.Cfg{
|
||||
RemoteCacheOptions: opts,
|
||||
},
|
||||
}
|
||||
|
||||
err := dc.Init()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init client for test. error: %v", err)
|
||||
}
|
||||
|
||||
return dc
|
||||
}
|
||||
|
||||
func TestCachedBasedOnConfig(t *testing.T) {
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
cfg.Load(&setting.CommandLineArgs{
|
||||
HomePath: "../../../",
|
||||
})
|
||||
|
||||
client := createTestClient(t, cfg.RemoteCacheOptions, sqlstore.InitTestDB(t))
|
||||
runTestsForClient(t, client)
|
||||
}
|
||||
|
||||
func TestInvalidCacheTypeReturnsError(t *testing.T) {
|
||||
_, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil)
|
||||
assert.Equal(t, err, ErrInvalidCacheType)
|
||||
}
|
||||
|
||||
func runTestsForClient(t *testing.T, client CacheStorage) {
|
||||
canPutGetAndDeleteCachedObjects(t, client)
|
||||
canNotFetchExpiredItems(t, client)
|
||||
}
|
||||
|
||||
func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) {
|
||||
cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
|
||||
|
||||
err := client.Set("key1", cacheableStruct, 0)
|
||||
assert.Equal(t, err, nil, "expected nil. got: ", err)
|
||||
|
||||
data, err := client.Get("key1")
|
||||
s, ok := data.(CacheableStruct)
|
||||
|
||||
assert.Equal(t, ok, true)
|
||||
assert.Equal(t, s.String, "hej")
|
||||
assert.Equal(t, s.Int64, int64(2000))
|
||||
|
||||
err = client.Delete("key1")
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
_, err = client.Get("key1")
|
||||
assert.Equal(t, err, ErrCacheItemNotFound)
|
||||
}
|
||||
|
||||
func canNotFetchExpiredItems(t *testing.T, client CacheStorage) {
|
||||
cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
|
||||
|
||||
err := client.Set("key1", cacheableStruct, time.Second)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
//not sure how this can be avoided when testing redis/memcached :/
|
||||
<-time.After(time.Second + time.Millisecond)
|
||||
|
||||
// should not be able to read that value since its expired
|
||||
_, err = client.Get("key1")
|
||||
assert.Equal(t, err, ErrCacheItemNotFound)
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
|
||||
return &m.UserToken{
|
||||
UserId: 0,
|
||||
UnhashedToken: "",
|
||||
@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
|
||||
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
|
||||
return &m.UserToken{
|
||||
UserId: 12,
|
||||
UnhashedToken: "",
|
||||
|
@ -3,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: "",
|
||||
|
@ -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())
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -75,6 +75,47 @@ func TestUserAuthToken(t *testing.T) {
|
||||
err = userAuthTokenService.RevokeToken(userToken)
|
||||
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
||||
})
|
||||
|
||||
Convey("When creating an additional token", func() {
|
||||
userToken2, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
|
||||
So(err, ShouldBeNil)
|
||||
So(userToken2, ShouldNotBeNil)
|
||||
|
||||
Convey("Can get first user token", func() {
|
||||
token, err := userAuthTokenService.GetUserToken(userID, userToken.Id)
|
||||
So(err, ShouldBeNil)
|
||||
So(token, ShouldNotBeNil)
|
||||
So(token.Id, ShouldEqual, userToken.Id)
|
||||
})
|
||||
|
||||
Convey("Can get second user token", func() {
|
||||
token, err := userAuthTokenService.GetUserToken(userID, userToken2.Id)
|
||||
So(err, ShouldBeNil)
|
||||
So(token, ShouldNotBeNil)
|
||||
So(token.Id, ShouldEqual, userToken2.Id)
|
||||
})
|
||||
|
||||
Convey("Can get user tokens", func() {
|
||||
tokens, err := userAuthTokenService.GetUserTokens(userID)
|
||||
So(err, ShouldBeNil)
|
||||
So(tokens, ShouldHaveLength, 2)
|
||||
So(tokens[0].Id, ShouldEqual, userToken.Id)
|
||||
So(tokens[1].Id, ShouldEqual, userToken2.Id)
|
||||
})
|
||||
|
||||
Convey("Can revoke all user tokens", func() {
|
||||
err := userAuthTokenService.RevokeAllUserTokens(userID)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
model, err := ctx.getAuthTokenByID(userToken.Id)
|
||||
So(err, ShouldBeNil)
|
||||
So(model, ShouldBeNil)
|
||||
|
||||
model2, err := ctx.getAuthTokenByID(userToken2.Id)
|
||||
So(err, ShouldBeNil)
|
||||
So(model2, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("expires correctly", func() {
|
||||
|
81
pkg/services/auth/testing.go
Normal file
81
pkg/services/auth/testing.go
Normal file
@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
import "github.com/grafana/grafana/pkg/models"
|
||||
|
||||
type FakeUserAuthTokenService struct {
|
||||
CreateTokenProvider func(userId int64, clientIP, userAgent string) (*models.UserToken, error)
|
||||
TryRotateTokenProvider func(token *models.UserToken, clientIP, userAgent string) (bool, error)
|
||||
LookupTokenProvider func(unhashedToken string) (*models.UserToken, error)
|
||||
RevokeTokenProvider func(token *models.UserToken) error
|
||||
RevokeAllUserTokensProvider func(userId int64) error
|
||||
ActiveAuthTokenCount func() (int64, error)
|
||||
GetUserTokenProvider func(userId, userTokenId int64) (*models.UserToken, error)
|
||||
GetUserTokensProvider func(userId int64) ([]*models.UserToken, error)
|
||||
}
|
||||
|
||||
func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
|
||||
return &FakeUserAuthTokenService{
|
||||
CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 0,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
},
|
||||
TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) {
|
||||
return &models.UserToken{
|
||||
UserId: 0,
|
||||
UnhashedToken: "",
|
||||
}, nil
|
||||
},
|
||||
RevokeTokenProvider: func(token *models.UserToken) error {
|
||||
return nil
|
||||
},
|
||||
RevokeAllUserTokensProvider: func(userId int64) error {
|
||||
return nil
|
||||
},
|
||||
ActiveAuthTokenCount: func() (int64, error) {
|
||||
return 10, nil
|
||||
},
|
||||
GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) {
|
||||
return nil, nil
|
||||
},
|
||||
GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
|
||||
return s.CreateTokenProvider(userId, clientIP, userAgent)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
|
||||
return s.LookupTokenProvider(unhashedToken)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
|
||||
return s.TryRotateTokenProvider(token, clientIP, userAgent)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error {
|
||||
return s.RevokeTokenProvider(token)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error {
|
||||
return s.RevokeAllUserTokensProvider(userId)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
|
||||
return s.ActiveAuthTokenCount()
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
|
||||
return s.GetUserTokenProvider(userId, userTokenId)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
|
||||
return s.GetUserTokensProvider(userId)
|
||||
}
|
22
pkg/services/sqlstore/migrations/cache_data_mig.go
Normal file
22
pkg/services/sqlstore/migrations/cache_data_mig.go
Normal file
@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
|
||||
func addCacheMigration(mg *migrator.Migrator) {
|
||||
var cacheDataV1 = migrator.Table{
|
||||
Name: "cache_data",
|
||||
Columns: []*migrator.Column{
|
||||
{Name: "cache_key", Type: migrator.DB_NVarchar, IsPrimaryKey: true, Length: 168},
|
||||
{Name: "data", Type: migrator.DB_Blob},
|
||||
{Name: "expires", Type: migrator.DB_Integer, Length: 255, Nullable: false},
|
||||
{Name: "created_at", Type: migrator.DB_Integer, Length: 255, Nullable: false},
|
||||
},
|
||||
Indices: []*migrator.Index{
|
||||
{Cols: []string{"cache_key"}, Type: migrator.UniqueIndex},
|
||||
},
|
||||
}
|
||||
|
||||
mg.AddMigration("create cache_data table", migrator.NewAddTableMigration(cacheDataV1))
|
||||
|
||||
mg.AddMigration("add unique index cache_data.cache_key", migrator.NewAddIndexMigration(cacheDataV1, cacheDataV1.Indices[0]))
|
||||
}
|
@ -33,6 +33,7 @@ func AddMigrations(mg *Migrator) {
|
||||
addUserAuthMigrations(mg)
|
||||
addServerlockMigrations(mg)
|
||||
addUserAuthTokenMigrations(mg)
|
||||
addCacheMigration(mg)
|
||||
}
|
||||
|
||||
func addMigrationLogMigrations(mg *Migrator) {
|
||||
|
@ -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{}
|
||||
|
@ -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
|
||||
}
|
||||
if setting.Env == setting.DEV {
|
||||
logger.Debug("getEngine", "connection", cnnstr)
|
||||
}
|
||||
|
||||
config := tsdb.SqlQueryEndpointConfiguration{
|
||||
DriverName: "mssql",
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
if setting.Env == setting.DEV {
|
||||
logger.Debug("getEngine", "connection", cnnstr)
|
||||
}
|
||||
|
||||
config := tsdb.SqlQueryEndpointConfiguration{
|
||||
DriverName: "mysql",
|
||||
|
@ -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)
|
||||
if setting.Env == setting.DEV {
|
||||
logger.Debug("getEngine", "connection", cnnstr)
|
||||
}
|
||||
|
||||
config := tsdb.SqlQueryEndpointConfiguration{
|
||||
DriverName: "postgres",
|
||||
|
@ -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,7 +68,15 @@ export class GrafanaApp {
|
||||
|
||||
moment.locale(config.bootData.user.locale);
|
||||
|
||||
app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => {
|
||||
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);
|
||||
|
||||
@ -86,9 +95,9 @@ export class GrafanaApp {
|
||||
$provide.decorator('$http', [
|
||||
'$delegate',
|
||||
'$templateCache',
|
||||
($delegate, $templateCache) => {
|
||||
($delegate: any, $templateCache: any) => {
|
||||
const get = $delegate.get;
|
||||
$delegate.get = (url, config) => {
|
||||
$delegate.get = (url: string, config: any) => {
|
||||
if (url.match(/\.html$/)) {
|
||||
// some template's already exist in the cache
|
||||
if (!$templateCache.get(url)) {
|
||||
@ -100,7 +109,8 @@ export class GrafanaApp {
|
||||
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);
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,30 @@
|
||||
// @ts-ignore
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { NavModelSrv } from 'app/core/nav_model_srv';
|
||||
import { ContextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
export interface Section {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
expanded: false;
|
||||
items: any[];
|
||||
url: string;
|
||||
icon: string;
|
||||
score: number;
|
||||
checked: boolean;
|
||||
hideHeader: boolean;
|
||||
toggle: Function;
|
||||
}
|
||||
|
||||
export interface FoldersAndDashboardUids {
|
||||
folderUids: string[];
|
||||
dashboardUids: string[];
|
||||
}
|
||||
|
||||
class Query {
|
||||
query: string;
|
||||
@ -14,7 +37,7 @@ class Query {
|
||||
}
|
||||
|
||||
export class ManageDashboardsCtrl {
|
||||
sections: any[];
|
||||
sections: Section[];
|
||||
|
||||
query: Query;
|
||||
navModel: any;
|
||||
@ -45,7 +68,12 @@ export class ManageDashboardsCtrl {
|
||||
hasEditPermissionInFolders: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
|
||||
constructor(
|
||||
private backendSrv: BackendSrv,
|
||||
navModelSrv: NavModelSrv,
|
||||
private searchSrv: SearchSrv,
|
||||
private contextSrv: ContextSrv
|
||||
) {
|
||||
this.isEditor = this.contextSrv.isEditor;
|
||||
this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders;
|
||||
|
||||
@ -73,7 +101,7 @@ export class ManageDashboardsCtrl {
|
||||
refreshList() {
|
||||
return this.searchSrv
|
||||
.search(this.query)
|
||||
.then(result => {
|
||||
.then((result: Section[]) => {
|
||||
return this.initDashboardList(result);
|
||||
})
|
||||
.then(() => {
|
||||
@ -81,7 +109,7 @@ export class ManageDashboardsCtrl {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
|
||||
return this.backendSrv.getFolderByUid(this.folderUid).then((folder: any) => {
|
||||
this.canSave = folder.canSave;
|
||||
if (!this.canSave) {
|
||||
this.hasEditPermissionInFolders = false;
|
||||
@ -90,7 +118,7 @@ export class ManageDashboardsCtrl {
|
||||
});
|
||||
}
|
||||
|
||||
initDashboardList(result: any) {
|
||||
initDashboardList(result: Section[]) {
|
||||
this.canMove = false;
|
||||
this.canDelete = false;
|
||||
this.selectAllChecked = false;
|
||||
@ -128,25 +156,25 @@ export class ManageDashboardsCtrl {
|
||||
this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
|
||||
}
|
||||
|
||||
getFoldersAndDashboardsToDelete() {
|
||||
const selectedDashboards = {
|
||||
folders: [],
|
||||
dashboards: [],
|
||||
getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids {
|
||||
const selectedDashboards: FoldersAndDashboardUids = {
|
||||
folderUids: [],
|
||||
dashboardUids: [],
|
||||
};
|
||||
|
||||
for (const section of this.sections) {
|
||||
if (section.checked && section.id !== 0) {
|
||||
selectedDashboards.folders.push(section.uid);
|
||||
selectedDashboards.folderUids.push(section.uid);
|
||||
} else {
|
||||
const selected = _.filter(section.items, { checked: true });
|
||||
selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
|
||||
selectedDashboards.dashboardUids.push(..._.map(selected, 'uid'));
|
||||
}
|
||||
}
|
||||
|
||||
return selectedDashboards;
|
||||
}
|
||||
|
||||
getFolderIds(sections) {
|
||||
getFolderIds(sections: Section[]) {
|
||||
const ids = [];
|
||||
for (const s of sections) {
|
||||
if (s.checked) {
|
||||
@ -158,8 +186,8 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
delete() {
|
||||
const data = this.getFoldersAndDashboardsToDelete();
|
||||
const folderCount = data.folders.length;
|
||||
const dashCount = data.dashboards.length;
|
||||
const folderCount = data.folderUids.length;
|
||||
const dashCount = data.dashboardUids.length;
|
||||
let text = 'Do you want to delete the ';
|
||||
let text2;
|
||||
|
||||
@ -179,12 +207,12 @@ export class ManageDashboardsCtrl {
|
||||
icon: 'fa-trash',
|
||||
yesText: 'Delete',
|
||||
onConfirm: () => {
|
||||
this.deleteFoldersAndDashboards(data.folders, data.dashboards);
|
||||
this.deleteFoldersAndDashboards(data.folderUids, data.dashboardUids);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private deleteFoldersAndDashboards(folderUids, dashboardUids) {
|
||||
private deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
|
||||
this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
|
||||
this.refreshList();
|
||||
});
|
||||
@ -219,13 +247,13 @@ export class ManageDashboardsCtrl {
|
||||
}
|
||||
|
||||
initTagFilter() {
|
||||
return this.searchSrv.getDashboardTags().then(results => {
|
||||
return this.searchSrv.getDashboardTags().then((results: any) => {
|
||||
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
|
||||
this.selectedTagFilter = this.tagFilterOptions[0];
|
||||
});
|
||||
}
|
||||
|
||||
filterByTag(tag) {
|
||||
filterByTag(tag: any) {
|
||||
if (_.indexOf(this.query.tag, tag) === -1) {
|
||||
this.query.tag.push(tag);
|
||||
}
|
||||
@ -243,7 +271,7 @@ export class ManageDashboardsCtrl {
|
||||
return res;
|
||||
}
|
||||
|
||||
removeTag(tag, evt) {
|
||||
removeTag(tag: any, evt: Event) {
|
||||
this.query.tag = _.without(this.query.tag, tag);
|
||||
this.refreshList();
|
||||
if (evt) {
|
||||
@ -269,7 +297,7 @@ export class ManageDashboardsCtrl {
|
||||
section.checked = this.selectAllChecked;
|
||||
}
|
||||
|
||||
section.items = _.map(section.items, item => {
|
||||
section.items = _.map(section.items, (item: any) => {
|
||||
item.checked = this.selectAllChecked;
|
||||
return item;
|
||||
});
|
||||
|
@ -1,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
|
||||
);
|
||||
}
|
||||
|
@ -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<Props, State> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -40,11 +40,15 @@ export default class Table extends PureComponent<TableProps> {
|
||||
const tableModel = data || EMPTY_TABLE;
|
||||
const columnNames = tableModel.columns.map(({ text }) => text);
|
||||
const columns = tableModel.columns.map(({ filterable, text }) => ({
|
||||
Header: text,
|
||||
Header: () => <span title={text}>{text}</span>,
|
||||
accessor: text,
|
||||
className: VALUE_REGEX.test(text) ? 'text-right' : '',
|
||||
show: text !== 'Time',
|
||||
Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
|
||||
Cell: row => (
|
||||
<span className={filterable ? 'link' : ''} title={text + ': ' + row.value}>
|
||||
{row.value}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
const noDataText = data ? 'The queries returned no data for a table.' : '';
|
||||
|
||||
|
@ -8,10 +8,8 @@ export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel);
|
||||
|
||||
reactPanel.setEditor(BarGaugePanelEditor);
|
||||
reactPanel.setDefaults(defaults);
|
||||
reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => {
|
||||
const options: Partial<BarGaugeOptions> = {};
|
||||
|
||||
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;
|
||||
|
@ -8,10 +8,8 @@ export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel);
|
||||
|
||||
reactPanel.setEditor(GaugePanelEditor);
|
||||
reactPanel.setDefaults(defaults);
|
||||
reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => {
|
||||
const options: Partial<GaugeOptions> = {};
|
||||
|
||||
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;
|
||||
|
@ -8,3 +8,10 @@ export const reactPanel = new ReactPanelPlugin<TextOptions>(TextPanel);
|
||||
|
||||
reactPanel.setEditor(TextPanelEditor);
|
||||
reactPanel.setDefaults(defaults);
|
||||
reactPanel.setPanelTypeChangedHook((options: TextOptions, prevPluginId: string, prevOptions: any) => {
|
||||
if (prevPluginId === 'text') {
|
||||
return prevOptions as TextOptions;
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
16
scripts/circle-test-cache-servers.sh
Executable file
16
scripts/circle-test-cache-servers.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
function exit_if_fail {
|
||||
command=$@
|
||||
echo "Executing '$command'"
|
||||
eval $command
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "'$command' returned $rc."
|
||||
exit $rc
|
||||
fi
|
||||
}
|
||||
|
||||
echo "running redis and memcache tests"
|
||||
|
||||
time exit_if_fail go test -tags=redis ./pkg/infra/remotecache/...
|
||||
time exit_if_fail go test -tags=memcached ./pkg/infra/remotecache/...
|
Loading…
Reference in New Issue
Block a user