Merge branch 'master' into bump-webpack

# Conflicts:
#	package.json
#	yarn.lock
This commit is contained in:
Johannes Schill
2019-03-20 09:43:33 +01:00
216 changed files with 7282 additions and 2841 deletions
+21 -4
View File
@@ -56,6 +56,20 @@ jobs:
name: postgres integration tests
command: './scripts/circle-test-postgres.sh'
cache-server-test:
docker:
- image: circleci/golang:1.11.5
- image: circleci/redis:4-alpine
- image: memcached
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
- run: dockerize -wait tcp://127.0.0.1:11211 -timeout 120s
- run: dockerize -wait tcp://127.0.0.1:6379 -timeout 120s
- run:
name: cache server tests
command: './scripts/circle-test-cache-servers.sh'
codespell:
docker:
- image: circleci/python
@@ -308,7 +322,7 @@ jobs:
deploy-enterprise-master:
docker:
- image: grafana/grafana-ci-deploy:1.2.0
- image: grafana/grafana-ci-deploy:1.2.1
steps:
- attach_workspace:
at: .
@@ -331,7 +345,7 @@ jobs:
deploy-enterprise-release:
docker:
- image: grafana/grafana-ci-deploy:1.2.0
- image: grafana/grafana-ci-deploy:1.2.1
steps:
- checkout
- attach_workspace:
@@ -364,7 +378,7 @@ jobs:
deploy-master:
docker:
- image: grafana/grafana-ci-deploy:1.2.0
- image: grafana/grafana-ci-deploy:1.2.1
steps:
- attach_workspace:
at: .
@@ -395,7 +409,7 @@ jobs:
deploy-release:
docker:
- image: grafana/grafana-ci-deploy:1.2.0
- image: grafana/grafana-ci-deploy:1.2.1
steps:
- checkout
- attach_workspace:
@@ -545,6 +559,8 @@ workflows:
filters: *filter-not-release-or-master
- postgres-integration-test:
filters: *filter-not-release-or-master
- cache-server-test:
filters: *filter-not-release-or-master
- grafana-docker-pr:
requires:
- build
@@ -554,4 +570,5 @@ workflows:
- gometalinter
- mysql-integration-test
- postgres-integration-test
- cache-server-test
filters: *filter-not-release-or-master
+15
View File
@@ -7,6 +7,11 @@
* **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow)
* **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683)
* **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683)
* **VictorOps**: Adds more information to the victor ops notifiers [#15744](https://github.com/grafana/grafana/issues/15744), thx [@zhulongcheng](https://github.com/zhulongcheng)
* **Cache**: Adds support for using out of proc caching in the backend [#10816](https://github.com/grafana/grafana/issues/10816)
* **Dataproxy**: Make it possible to add user details to requests sent to the dataproxy [#6359](https://github.com/grafana/grafana/issues/6359) and [#15931](https://github.com/grafana/grafana/issues/15931)
* **Auth**: Support listing and revoking auth tokens via API [#15836](https://github.com/grafana/grafana/issues/15836)
* **Datasource**: Only log connection string in dev environment [#16001](https://github.com/grafana/grafana/issues/16001)
### Bug Fixes
* **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506)
@@ -15,6 +20,16 @@
* **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619)
* **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019)
# 6.0.2 (2019-03-19)
### Bug Fixes
* **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo)
* **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo)
* **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr)
* **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr)
* **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg)
* **Datasource admin**: Fixed url of back button in datasource edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop)
# 6.0.1 (2019-03-06)
### Bug Fixes
+15 -1
View File
@@ -106,6 +106,17 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
#################################### Cache server #############################
[remote_cache]
# Either "redis", "memcached" or "database" default is "database"
type = database
# cache connectionstring options
# database: will use Grafana primary database.
# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
# memcache: 127.0.0.1:11211
connstr =
#################################### Session #############################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -146,6 +157,9 @@ logging = false
# How long the data proxy should wait before timing out default is 30 (seconds)
timeout = 30
# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
send_user_header = false
#################################### Analytics ###########################
[analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@@ -245,7 +259,7 @@ external_manage_info =
viewers_can_edit = false
# Editors can administrate dashboard, folders and teams they create
editors_can_own = false
editors_can_admin = false
[auth]
# Login cookie name
+15 -1
View File
@@ -102,6 +102,17 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
#################################### Cache server #############################
[remote_cache]
# Either "redis", "memcached" or "database" default is "database"
;type = database
# cache connectionstring options
# database: will use Grafana primary database.
# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
# memcache: 127.0.0.1:11211
;connstr =
#################################### Session ####################################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -133,6 +144,9 @@ log_queries =
# How long the data proxy should wait before timing out default is 30 (seconds)
;timeout = 30
# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
;send_user_header = false
#################################### Analytics ####################################
[analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@@ -225,7 +239,7 @@ log_queries =
;viewers_can_edit = false
# Editors can administrate dashboard, folders and teams they create
;editors_can_own = false
;editors_can_admin = false
[auth]
# Login cookie name
@@ -1,4 +1,4 @@
memcached:
redis:
image: redis:latest
ports:
- "6379:6379"
+2
View File
@@ -38,6 +38,8 @@ With a Grafana Enterprise license you will get access to premium plugins, includ
* [DataDog](https://grafana.com/plugins/grafana-datadog-datasource)
* [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource)
* [New Relic](https://grafana.com/plugins/grafana-newrelic-datasource)
* [Amazon Timestream](https://grafana.com/plugins/grafana-timestream-datasource)
* [Oracle Database](https://grafana.com/plugins/grafana-oracle-datasource)
## Try Grafana Enterprise
+102
View File
@@ -341,3 +341,105 @@ Content-Type: application/json
{"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100}
```
## Auth tokens for User
`GET /api/admin/users/:id/auth-tokens`
Return a list of all auth tokens (devices) that the user currently have logged in from.
Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
**Example Request**:
```http
GET /api/admin/users/1/auth-tokens HTTP/1.1
Accept: application/json
Content-Type: application/json
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 361,
"isActive": false,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
"createdAt": "2019-03-05T21:22:54+01:00",
"seenAt": "2019-03-06T19:41:06+01:00"
},
{
"id": 364,
"isActive": false,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
"createdAt": "2019-03-06T19:41:19+01:00",
"seenAt": "2019-03-06T19:41:21+01:00"
}
]
```
## Revoke auth token for User
`POST /api/admin/users/:id/revoke-auth-token`
Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in
and will be required to authenticate again upon next activity.
Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
**Example Request**:
```http
POST /api/admin/users/1/revoke-auth-token HTTP/1.1
Accept: application/json
Content-Type: application/json
{
"authTokenId": 364
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
"message": "User auth token revoked"
}
```
## Logout User
`POST /api/admin/users/:id/logout`
Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in
and will be required to authenticate again upon next activity.
Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
**Example Request**:
```http
POST /api/admin/users/1/logout HTTP/1.1
Accept: application/json
Content-Type: application/json
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
"message": "User auth token revoked"
}
```
+72
View File
@@ -478,3 +478,75 @@ Content-Type: application/json
{"message":"Dashboard unstarred"}
```
## Auth tokens of the actual User
`GET /api/user/auth-tokens`
Return a list of all auth tokens (devices) that the actual user currently have logged in from.
**Example Request**:
```http
GET /api/user/auth-tokens HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 361,
"isActive": true,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
"createdAt": "2019-03-05T21:22:54+01:00",
"seenAt": "2019-03-06T19:41:06+01:00"
},
{
"id": 364,
"isActive": false,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
"createdAt": "2019-03-06T19:41:19+01:00",
"seenAt": "2019-03-06T19:41:21+01:00"
}
]
```
## Revoke an auth token of the actual User
`POST /api/user/revoke-auth-token`
Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in
and will be required to authenticate again upon next activity.
**Example Request**:
```http
POST /api/user/revoke-auth-token HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"authTokenId": 364
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
"message": "User auth token revoked"
}
```
+34 -1
View File
@@ -179,7 +179,6 @@ Path to the certificate key file (if `protocol` is set to `https`).
Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events
to grafana log.
<hr />
<hr />
@@ -262,6 +261,19 @@ Set to `true` to log the sql calls and execution times.
For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
Defaults to private.
<hr />
## [remote_cache]
### type
Either `redis`, `memcached` or `database` default is `database`
### connstr
The remote cache connection string. Leave empty when using `database` since it will use the primary database.
Redis example config: `addr=127.0.0.1:6379,pool_size=100,db=grafana`
Memcache example: `127.0.0.1:11211`
<hr />
@@ -342,6 +354,11 @@ options are `Admin` and `Editor`. e.g. :
Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
Defaults to `false`.
### editors_can_admin
Editors can administrate dashboards, folders and teams they create.
Defaults to `false`.
### login_hint
Text used as placeholder text on login page for login/username input.
@@ -399,6 +416,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
<hr />
## [dataproxy]
### logging
This enables data proxy logging, default is false.
### timeout
How long the data proxy should wait before timing out default is 30 (seconds)
### send_user_header
If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
<hr />
## [analytics]
### reporting_enabled
@@ -28,6 +28,9 @@ Can do everything scoped to the organization. For example:
- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
- **Cannot** create or edit data sources nor invite new users.
This role can be tweaked via Grafana server setting [editors_can_admin]({{< relref "installation/configuration.md#editors_can_admin" >}}). If you set this to true users
with **Editor** can also administrate dashboards, folders and teams they create. Useful for enabling self organizing teams.
## Viewer Role
- View any dashboard. This can be disabled on specific folders and dashboards.
+1 -1
View File
@@ -110,7 +110,7 @@ Formats single & multi valued variables for use in URL parameters.
```bash
servers = ['foo()bar BAZ', 'test2']
String to interpolate: '${servers:lucene}'
String to interpolate: '${servers:percentencode}'
Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2'
```
+2 -2
View File
@@ -1,4 +1,4 @@
{
"stable": "6.0.1",
"testing": "6.0.1"
"stable": "6.0.2",
"testing": "6.0.2"
}
+7 -6
View File
@@ -28,12 +28,13 @@
"@types/jquery": "^1.10.35",
"@types/node": "^8.0.31",
"@types/papaparse": "^4.5.9",
"@types/react": "^16.7.6",
"@types/react-dom": "^16.0.9",
"@types/react": "^16.8.8",
"@types/react-dom": "^16.8.2",
"@types/react-grid-layout": "^0.16.6",
"@types/react-select": "^2.0.4",
"@types/react-transition-group": "^2.0.15",
"@types/react-virtualized": "^9.18.12",
"@types/clipboard": "^2.0.1",
"angular-mocks": "1.6.6",
"autoprefixer": "^9.4.10",
"axios": "^0.18.0",
@@ -177,7 +178,7 @@
"baron": "^3.0.3",
"brace": "^0.10.0",
"classnames": "^2.2.6",
"clipboard": "^1.7.1",
"clipboard": "^2.0.4",
"d3": "^4.11.0",
"d3-scale-chromatic": "^1.3.0",
"eventemitter3": "^2.0.3",
@@ -193,8 +194,8 @@
"prismjs": "^1.6.0",
"prop-types": "^15.6.2",
"rc-cascader": "^0.14.0",
"react": "^16.6.3",
"react-dom": "^16.6.3",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-grid-layout": "0.16.6",
"react-highlight-words": "0.11.0",
"react-popper": "^1.3.0",
@@ -221,7 +222,7 @@
},
"resolutions": {
"caniuse-db": "1.0.30000772",
"**/@types/react": "16.7.6"
"**/@types/react": "16.8.8"
},
"workspaces": {
"packages": [
+3 -3
View File
@@ -25,10 +25,10 @@
"lodash": "^4.17.10",
"moment": "^2.22.2",
"papaparse": "^4.6.3",
"react": "^16.6.3",
"react": "^16.8.4",
"react-color": "^2.17.0",
"react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.6.3",
"react-dom": "^16.8.4",
"react-highlight-words": "0.11.0",
"react-popper": "^1.3.0",
"react-transition-group": "^2.2.1",
@@ -48,7 +48,7 @@
"@types/lodash": "^4.14.119",
"@types/node": "^10.12.18",
"@types/papaparse": "^4.5.9",
"@types/react": "^16.7.6",
"@types/react": "^16.8.8",
"@types/react-custom-scrollbars": "^4.0.5",
"@types/react-test-renderer": "^16.0.3",
"@types/react-transition-group": "^2.0.15",
@@ -1,6 +1,7 @@
import { storiesOf } from '@storybook/react';
import { number, text } from '@storybook/addon-knobs';
import { number, text, boolean } from '@storybook/addon-knobs';
import { BarGauge } from './BarGauge';
import { VizOrientation } from '../../types';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
@@ -15,6 +16,8 @@ const getKnobs = () => {
threshold2Color: text('threshold2Color', 'red'),
unit: text('unit', 'ms'),
decimals: number('decimals', 1),
horizontal: boolean('horizontal', false),
lcd: boolean('lcd', false),
};
};
@@ -22,7 +25,7 @@ const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module);
BarGaugeStories.addDecorator(withCenteredStory);
BarGaugeStories.add('Vertical, with basic thresholds', () => {
BarGaugeStories.add('Simple with basic thresholds', () => {
const {
value,
minValue,
@@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
threshold2Value,
unit,
decimals,
horizontal,
lcd,
} = getKnobs();
return renderComponentWithTheme(BarGauge, {
width: 200,
height: 400,
width: 700,
height: 700,
value: value,
minValue: minValue,
maxValue: maxValue,
@@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
prefix: '',
postfix: '',
decimals: decimals,
orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical,
displayMode: lcd ? 'lcd' : 'simple',
thresholds: [
{ index: 0, value: -Infinity, color: 'green' },
{ index: 1, value: threshold1Value, color: threshold1Color },
@@ -11,16 +11,15 @@ jest.mock('jquery', () => ({
const setup = (propOverrides?: object) => {
const props: Props = {
maxValue: 100,
valueMappings: [],
minValue: 0,
prefix: '',
suffix: '',
displayMode: 'basic',
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
unit: 'none',
height: 300,
width: 300,
value: 25,
decimals: 0,
value: {
text: '25',
numeric: 25,
},
theme: getTheme(),
orientation: VizOrientation.Horizontal,
};
@@ -1,55 +1,54 @@
// Library
import React, { PureComponent, CSSProperties } from 'react';
import React, { PureComponent, CSSProperties, ReactNode } from 'react';
import tinycolor from 'tinycolor2';
// Utils
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils';
// Types
import { Themeable, TimeSeriesValue, Threshold, ValueMapping, VizOrientation } from '../../types';
import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types';
const BAR_SIZE_RATIO = 0.8;
export interface Props extends Themeable {
height: number;
unit: string;
width: number;
thresholds: Threshold[];
valueMappings: ValueMapping[];
value: TimeSeriesValue;
value: DisplayValue;
maxValue: number;
minValue: number;
orientation: VizOrientation;
prefix?: string;
suffix?: string;
decimals?: number;
displayMode: 'basic' | 'lcd' | 'gradient';
}
/*
* This visualization is still in POC state, needed more tests & better structure
*/
export class BarGauge extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
maxValue: 100,
minValue: 0,
value: 100,
unit: 'none',
value: {
text: '100',
numeric: 100,
},
displayMode: 'lcd',
orientation: VizOrientation.Horizontal,
thresholds: [],
valueMappings: [],
};
getNumericValue(): number {
if (Number.isFinite(this.props.value as number)) {
return this.props.value as number;
render() {
switch (this.props.displayMode) {
case 'lcd':
return this.renderRetroBars();
case 'basic':
case 'gradient':
default:
return this.renderBasicAndGradientBars();
}
return 0;
}
getValueColors(): BarColors {
const { thresholds, theme, value } = this.props;
const activeThreshold = getThresholdForValue(thresholds, value);
const activeThreshold = getThresholdForValue(thresholds, value.numeric);
if (activeThreshold !== null) {
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
@@ -57,41 +56,19 @@ export class BarGauge extends PureComponent<Props> {
return {
value: color,
border: color,
bar: tinycolor(color)
.setAlpha(0.3)
background: tinycolor(color)
.setAlpha(0.15)
.toRgbString(),
};
}
return {
value: getColorFromHexRgbOrName('gray', theme.type),
bar: getColorFromHexRgbOrName('gray', theme.type),
background: getColorFromHexRgbOrName('gray', theme.type),
border: getColorFromHexRgbOrName('gray', theme.type),
};
}
getCellColor(positionValue: TimeSeriesValue): string {
const { thresholds, theme, value } = this.props;
const activeThreshold = getThresholdForValue(thresholds, positionValue);
if (activeThreshold !== null) {
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
// if we are past real value the cell is not "on"
if (value === null || (positionValue !== null && positionValue > value)) {
return tinycolor(color)
.setAlpha(0.15)
.toRgbString();
} else {
return tinycolor(color)
.setAlpha(0.7)
.toRgbString();
}
}
return 'gray';
}
getValueStyles(value: string, color: string, width: number): CSSProperties {
const guess = width / (value.length * 1.1);
const fontSize = Math.min(Math.max(guess, 14), 40);
@@ -102,107 +79,205 @@ export class BarGauge extends PureComponent<Props> {
};
}
renderVerticalBar(valueFormatted: string, valuePercent: number) {
/*
* Return width or height depending on viz orientation
* */
get size() {
const { height, width } = this.props;
return this.isVertical ? height : width;
}
const maxHeight = height * BAR_SIZE_RATIO;
const barHeight = Math.max(valuePercent * maxHeight, 0);
get isVertical() {
return this.props.orientation === VizOrientation.Vertical;
}
getBarGradient(maxSize: number): string {
const { minValue, maxValue, thresholds, value } = this.props;
const cssDirection = this.isVertical ? '0deg' : '90deg';
let gradient = '';
let lastpos = 0;
for (let i = 0; i < thresholds.length; i++) {
const threshold = thresholds[i];
const color = getColorFromHexRgbOrName(threshold.color);
const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1);
const pos = valuePercent * maxSize;
const offset = Math.round(pos - (pos - lastpos) / 2);
if (gradient === '') {
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
} else if (value.numeric < threshold.value) {
break;
} else {
lastpos = pos;
gradient += ` ${offset}px, ${color}`;
}
}
return gradient + ')';
}
renderBasicAndGradientBars(): ReactNode {
const { height, width, displayMode, maxValue, minValue, value } = this.props;
const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
const maxSize = this.size * BAR_SIZE_RATIO;
const barSize = Math.max(valuePercent * maxSize, 0);
const colors = this.getValueColors();
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width);
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
const isBasic = displayMode === 'basic';
const containerStyles: CSSProperties = {
width: `${width}px`,
height: `${height}px`,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
};
const barStyles: CSSProperties = {
height: `${barHeight}px`,
width: `${width}px`,
backgroundColor: colors.bar,
borderTop: `1px solid ${colors.border}`,
borderRadius: '3px',
};
if (this.isVertical) {
// Custom styles for vertical orientation
containerStyles.flexDirection = 'column';
containerStyles.justifyContent = 'flex-end';
barStyles.transition = 'height 1s';
barStyles.height = `${barSize}px`;
barStyles.width = `${width}px`;
if (isBasic) {
// Basic styles
barStyles.background = `${colors.background}`;
barStyles.border = `1px solid ${colors.border}`;
barStyles.boxShadow = `0 0 4px ${colors.border}`;
} else {
// Gradient styles
barStyles.background = this.getBarGradient(maxSize);
}
} else {
// Custom styles for horizontal orientation
containerStyles.flexDirection = 'row-reverse';
containerStyles.justifyContent = 'flex-end';
containerStyles.alignItems = 'center';
barStyles.transition = 'width 1s';
barStyles.height = `${height}px`;
barStyles.width = `${barSize}px`;
barStyles.marginRight = '10px';
if (isBasic) {
// Basic styles
barStyles.background = `${colors.background}`;
barStyles.border = `1px solid ${colors.border}`;
barStyles.boxShadow = `0 0 4px ${colors.border}`;
} else {
// Gradient styles
barStyles.background = this.getBarGradient(maxSize);
}
}
return (
<div style={containerStyles}>
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
{value.text}
</div>
<div style={barStyles} />
</div>
);
}
renderHorizontalBar(valueFormatted: string, valuePercent: number) {
const { height, width } = this.props;
getCellColor(positionValue: TimeSeriesValue): CellColors {
const { thresholds, theme, value } = this.props;
const activeThreshold = getThresholdForValue(thresholds, positionValue);
const maxWidth = width * BAR_SIZE_RATIO;
const barWidth = Math.max(valuePercent * maxWidth, 0);
const colors = this.getValueColors();
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
if (activeThreshold !== null) {
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
valueStyles.marginLeft = '8px';
// if we are past real value the cell is not "on"
if (value === null || (positionValue !== null && positionValue > value.numeric)) {
return {
background: tinycolor(color)
.setAlpha(0.15)
.toRgbString(),
border: 'transparent',
isLit: false,
};
} else {
return {
background: tinycolor(color)
.setAlpha(0.85)
.toRgbString(),
backgroundShade: tinycolor(color)
.setAlpha(0.55)
.toRgbString(),
border: tinycolor(color)
.setAlpha(0.9)
.toRgbString(),
isLit: true,
};
}
}
const containerStyles: CSSProperties = {
width: `${width}px`,
height: `${height}px`,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
return {
background: 'gray',
border: 'gray',
};
const barStyles = {
height: `${height}px`,
width: `${barWidth}px`,
backgroundColor: colors.bar,
borderRight: `1px solid ${colors.border}`,
};
return (
<div style={containerStyles}>
<div style={barStyles} />
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
</div>
</div>
);
}
renderHorizontalLCD(valueFormatted: string, valuePercent: number) {
const { height, width, maxValue, minValue } = this.props;
renderRetroBars(): ReactNode {
const { height, width, maxValue, minValue, value } = this.props;
const valueRange = maxValue - minValue;
const maxWidth = width * BAR_SIZE_RATIO;
const cellSpacing = 4;
const cellCount = 30;
const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount;
const maxSize = this.size * BAR_SIZE_RATIO;
const cellSpacing = 5;
const cellCount = maxSize / 20;
const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
const colors = this.getValueColors();
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
valueStyles.marginLeft = '8px';
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
const containerStyles: CSSProperties = {
width: `${width}px`,
height: `${height}px`,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
};
if (this.isVertical) {
containerStyles.flexDirection = 'column-reverse';
containerStyles.alignItems = 'center';
valueStyles.marginBottom = '20px';
} else {
containerStyles.flexDirection = 'row';
containerStyles.alignItems = 'center';
valueStyles.marginLeft = '20px';
}
const cells: JSX.Element[] = [];
for (let i = 0; i < cellCount; i++) {
const currentValue = (valueRange / cellCount) * i;
const cellColor = this.getCellColor(currentValue);
const cellStyles: CSSProperties = {
width: `${cellWidth}px`,
backgroundColor: cellColor,
marginRight: '4px',
height: `${height}px`,
borderRadius: '2px',
};
if (cellColor.isLit) {
cellStyles.boxShadow = `0 0 4px ${cellColor.border}`;
cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`;
} else {
cellStyles.backgroundColor = cellColor.background;
}
if (this.isVertical) {
cellStyles.height = `${cellSize}px`;
cellStyles.width = `${width}px`;
cellStyles.marginTop = `${cellSpacing}px`;
} else {
cellStyles.width = `${cellSize}px`;
cellStyles.height = `${height}px`;
cellStyles.marginRight = `${cellSpacing}px`;
}
cells.push(<div style={cellStyles} />);
}
@@ -210,30 +285,22 @@ export class BarGauge extends PureComponent<Props> {
<div style={containerStyles}>
{cells}
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
{value.text}
</div>
</div>
);
}
render() {
const { maxValue, minValue, orientation, unit, decimals } = this.props;
const numericValue = this.getNumericValue();
const valuePercent = Math.min(numericValue / (maxValue - minValue), 1);
const formatFunc = getValueFormat(unit);
const valueFormatted = formatFunc(numericValue, decimals);
const vertical = orientation === 'vertical';
return vertical
? this.renderVerticalBar(valueFormatted, valuePercent)
: this.renderHorizontalLCD(valueFormatted, valuePercent);
}
}
interface BarColors {
value: string;
bar: string;
background: string;
border: string;
}
interface CellColors {
background: string;
backgroundShade?: string;
border: string;
isLit?: boolean;
}
@@ -6,353 +6,37 @@ exports[`Render BarGauge with basic options should render 1`] = `
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"flexDirection": "row-reverse",
"height": "300px",
"justifyContent": "flex-end",
"width": "300px",
}
}
>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
className="bar-gauge__value"
style={
Object {
"color": "#7EB26D",
"fontSize": "27.272727272727263px",
"marginLeft": "8px",
"fontSize": "27.27272727272727px",
}
}
>
25
</div>
<div
style={
Object {
"background": "rgba(126, 178, 109, 0.15)",
"border": "1px solid #7EB26D",
"borderRadius": "3px",
"boxShadow": "0 0 4px #7EB26D",
"height": "300px",
"marginRight": "10px",
"transition": "width 1s",
"width": "60px",
}
}
/>
</div>
`;
@@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
interface Props {
onConfirm(): void;
disabled?: boolean;
}
interface State {
@@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
};
render() {
const { onConfirm } = this.props;
let showConfirm;
let showDeleteButton;
if (this.state.showConfirm) {
showConfirm = 'show';
showDeleteButton = 'hide';
} else {
showConfirm = 'hide';
showDeleteButton = 'show';
}
const { onConfirm, disabled } = this.props;
const showConfirmClass = this.state.showConfirm ? 'show' : 'hide';
const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show';
const disabledClass = disabled ? 'disabled btn-inverse' : '';
const onClick = disabled ? () => {} : this.onClickDelete;
return (
<span className="delete-button-container">
<a className={'delete-button ' + showDeleteButton + ' btn btn-danger btn-small'} onClick={this.onClickDelete}>
<a
className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`}
onClick={onClick}
>
<i className="fa fa-remove" />
</a>
<span className="confirm-delete-container">
<span className={'confirm-delete ' + showConfirm}>
<span className={`confirm-delete ${showConfirmClass}`}>
<a className="btn btn-small" onClick={this.onClickCancel}>
Cancel
</a>
@@ -2,7 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Gauge, Props } from './Gauge';
import { ValueMapping, MappingType } from '../../types';
import { getTheme } from '../../themes';
jest.mock('jquery', () => ({
@@ -12,19 +11,16 @@ jest.mock('jquery', () => ({
const setup = (propOverrides?: object) => {
const props: Props = {
maxValue: 100,
valueMappings: [],
minValue: 0,
prefix: '',
showThresholdMarkers: true,
showThresholdLabels: false,
suffix: '',
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
unit: 'none',
stat: 'avg',
height: 300,
width: 300,
value: 25,
decimals: 0,
value: {
text: '25',
numeric: 25,
},
theme: getTheme(),
};
@@ -39,38 +35,6 @@ const setup = (propOverrides?: object) => {
};
};
describe('Get font color', () => {
it('should get first threshold color when only one threshold', () => {
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
expect(instance.getFontColor(49)).toEqual('#7EB26D');
});
it('should get the threshold color if value is same as a threshold', () => {
const { instance } = setup({
thresholds: [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
],
});
expect(instance.getFontColor(50)).toEqual('#EAB839');
});
it('should get the nearest threshold color between thresholds', () => {
const { instance } = setup({
thresholds: [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
],
});
expect(instance.getFontColor(55)).toEqual('#EAB839');
});
});
describe('Get thresholds formatted', () => {
it('should return first thresholds color for min and max', () => {
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
@@ -98,51 +62,3 @@ describe('Get thresholds formatted', () => {
]);
});
});
describe('Format value', () => {
it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = [];
const value = 'N/A';
const { instance } = setup({ valueMappings });
const result = instance.formatValue(value);
expect(result).toEqual('N/A');
});
it('should return formatted value if there are no value mappings', () => {
const valueMappings: ValueMapping[] = [];
const value = '6';
const { instance } = setup({ valueMappings, decimals: 1 });
const result = instance.formatValue(value);
expect(result).toEqual('6.0');
});
it('should return formatted value if there are no matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const { instance } = setup({ valueMappings, decimals: 1 });
const result = instance.formatValue(value);
expect(result).toEqual('10.0');
});
it('should return mapped value if there are matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '11';
const { instance } = setup({ valueMappings, decimals: 1 });
const result = instance.formatValue(value);
expect(result).toEqual('1-20');
});
});
@@ -1,28 +1,20 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
import { ValueMapping, Threshold, GrafanaThemeType } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
import { Threshold, GrafanaThemeType } from '../../types';
import { getColorFromHexRgbOrName } from '../../utils';
import { Themeable } from '../../index';
type GaugeValue = string | number | null;
import { DisplayValue } from '../../utils/displayValue';
export interface Props extends Themeable {
decimals?: number | null;
height: number;
valueMappings: ValueMapping[];
maxValue: number;
minValue: number;
prefix: string;
thresholds: Threshold[];
showThresholdMarkers: boolean;
showThresholdLabels: boolean;
stat: string;
suffix: string;
unit: string;
width: number;
value: number;
value: DisplayValue;
}
const FONT_SCALE = 1;
@@ -32,15 +24,10 @@ export class Gauge extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
maxValue: 100,
valueMappings: [],
minValue: 0,
prefix: '',
showThresholdMarkers: true,
showThresholdLabels: false,
suffix: '',
thresholds: [],
unit: 'none',
stat: 'avg',
};
componentDidMount() {
@@ -51,39 +38,6 @@ export class Gauge extends PureComponent<Props> {
this.draw();
}
formatValue(value: GaugeValue) {
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
if (isNaN(value as number)) {
return value;
}
if (valueMappings.length > 0) {
const valueMappedValue = getMappedValue(valueMappings, value);
if (valueMappedValue) {
return `${prefix && prefix + ' '}${valueMappedValue.text}${suffix && ' ' + suffix}`;
}
}
const formatFunc = getValueFormat(unit);
const formattedValue = formatFunc(value as number, decimals);
const handleNoValueValue = formattedValue || 'no value';
return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
}
getFontColor(value: GaugeValue): string {
const { thresholds, theme } = this.props;
const activeThreshold = getThresholdForValue(thresholds, value);
if (activeThreshold !== null) {
return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
}
return '';
}
getFormattedThresholds() {
const { maxValue, minValue, thresholds, theme } = this.props;
@@ -112,15 +66,13 @@ export class Gauge extends PureComponent<Props> {
draw() {
const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props;
const formattedValue = this.formatValue(value) as string;
const dimension = Math.min(width, height * 1.3);
const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3;
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
const thresholdMarkersWidth = gaugeWidth / 5;
const fontSize =
Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1);
const fontSize = Math.min(dimension / 5, 100) * (value.text !== null ? this.getFontScale(value.text.length) : 1);
const thresholdLabelFontSize = fontSize / 2.5;
const options: any = {
@@ -149,9 +101,9 @@ export class Gauge extends PureComponent<Props> {
width: thresholdMarkersWidth,
},
value: {
color: this.getFontColor(value),
color: value.color,
formatter: () => {
return formattedValue;
return value.text;
},
font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
},
@@ -160,7 +112,7 @@ export class Gauge extends PureComponent<Props> {
},
};
const plotSeries = { data: [[0, value]] };
const plotSeries = { data: [[0, value.numeric]] };
try {
$.plot(this.canvasElement, [plotSeries], options);
@@ -1,18 +1,16 @@
import React from 'react';
import React from 'react';
import renderer from 'react-test-renderer';
import { shallow } from 'enzyme';
import { Input, EventsWithValidation } from './Input';
import { ValidationEvents } from 'app/types';
import { Input } from './Input';
import { EventsWithValidation } from '../../utils';
import { ValidationEvents } from '../../types';
const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
const testBlurValidation: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: (value: string) => {
if (!value || value.length < 3) {
return true;
}
return false;
return !value || value.length < 3;
},
errorMessage: TEST_ERROR_MESSAGE,
},
@@ -1,26 +1,13 @@
import React, { PureComponent } from 'react';
import React, { PureComponent, ChangeEvent } from 'react';
import classNames from 'classnames';
import { ValidationEvents, ValidationRule } from 'app/types';
import { validate, hasValidationEvent } from 'app/core/utils/validate';
import { validate, EventsWithValidation, hasValidationEvent } from '../../utils';
import { ValidationEvents, ValidationRule } from '../../types';
export enum InputStatus {
Invalid = 'invalid',
Valid = 'valid',
}
export enum InputTypes {
Text = 'text',
Number = 'number',
Password = 'password',
Email = 'email',
}
export enum EventsWithValidation {
onBlur = 'onBlur',
onFocus = 'onFocus',
onChange = 'onChange',
}
interface Props extends React.HTMLProps<HTMLInputElement> {
validationEvents?: ValidationEvents;
hideErrorMessage?: boolean;
@@ -28,7 +15,7 @@ interface Props extends React.HTMLProps<HTMLInputElement> {
// Override event props and append status as argument
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: InputStatus) => void;
}
export class Input extends PureComponent<Props> {
@@ -49,24 +36,24 @@ export class Input extends PureComponent<Props> {
}
validatorAsync = (validationRules: ValidationRule[]) => {
return evt => {
return (evt: ChangeEvent<HTMLInputElement>) => {
const errors = validate(evt.target.value, validationRules);
this.setState(prevState => {
return {
...prevState,
error: errors ? errors[0] : null,
};
return { ...prevState, error: errors ? errors[0] : null };
});
};
};
populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
const inputElementProps = { ...restProps };
Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => {
if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) {
inputElementProps[eventName] = async evt => {
if (!validationEvents) {
return inputElementProps;
}
Object.keys(EventsWithValidation).forEach(eventName => {
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
if (hasValidationEvent(eventName, validationEvents)) {
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
}
if (restProps[eventName]) {
@@ -25,6 +25,7 @@ const model: OptionProps<any> = {
key: '',
onClick: jest.fn(),
onMouseOver: jest.fn(),
onMouseMove: jest.fn(),
tabIndex: 1,
},
label: 'Option label',
@@ -4,6 +4,7 @@ exports[`SelectOption renders correctly 1`] = `
<div
id=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
onMouseOver={[MockFunction]}
tabIndex={1}
>
@@ -40,8 +40,6 @@ export function makeDummyTable(columnCount: number, rowCount: number): TableData
const suffix = (rowId + 1).toString();
return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
}),
type: 'table',
columnMap: {},
};
}
@@ -8,6 +8,7 @@ import {
CellMeasurerCache,
CellMeasurer,
GridCellProps,
Index,
} from 'react-virtualized';
import { Themeable } from '../../types/theme';
@@ -26,6 +27,7 @@ import { stringToJsRegex } from '../../utils/index';
export interface Props extends Themeable {
data: TableData;
minColumnWidth: number;
showHeader: boolean;
fixedHeader: boolean;
fixedColumns: number;
@@ -46,6 +48,7 @@ interface State {
interface ColumnRenderInfo {
header: string;
width: number;
builder: TableCellBuilder;
}
@@ -64,6 +67,7 @@ export class Table extends Component<Props, State> {
fixedHeader: true,
fixedColumns: 0,
rotate: false,
minColumnWidth: 150,
};
constructor(props: Props) {
@@ -76,7 +80,7 @@ export class Table extends Component<Props, State> {
this.renderer = this.initColumns(props);
this.measurer = new CellMeasurerCache({
defaultHeight: 30,
defaultWidth: 150,
fixedWidth: true,
});
}
@@ -110,7 +114,8 @@ export class Table extends Component<Props, State> {
/** Given the configuration, setup how each column gets rendered */
initColumns(props: Props): ColumnRenderInfo[] {
const { styles, data } = props;
const { styles, data, width, minColumnWidth } = props;
const columnWidth = Math.max(width / data.columns.length, minColumnWidth);
return data.columns.map((col, index) => {
let title = col.text;
@@ -131,6 +136,7 @@ export class Table extends Component<Props, State> {
return {
header: title,
width: columnWidth,
builder: getCellBuilder(col, style, this.props),
};
});
@@ -228,6 +234,10 @@ export class Table extends Component<Props, State> {
);
};
getColumnWidth = (col: Index): number => {
return this.renderer[col.index].width;
};
render() {
const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props;
const { data } = this.state;
@@ -269,7 +279,7 @@ export class Table extends Component<Props, State> {
rowCount={rowCount}
overscanColumnCount={8}
overscanRowCount={8}
columnWidth={this.measurer.columnWidth}
columnWidth={this.getColumnWidth}
deferredMeasurementCache={this.measurer}
cellRenderer={this.cellRenderer}
rowHeight={this.measurer.rowHeight}
@@ -0,0 +1,16 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { ThresholdsEditor } from './ThresholdsEditor';
const ThresholdsEditorStories = storiesOf('UI/ThresholdsEditor', module);
const thresholds = [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 50, color: 'red' }];
ThresholdsEditorStories.add('default', () => {
return <ThresholdsEditor thresholds={[]} onChange={action('Thresholds changed')} />;
});
ThresholdsEditorStories.add('with thresholds', () => {
return <ThresholdsEditor thresholds={thresholds} onChange={action('Thresholds changed')} />;
});
@@ -1,6 +1,7 @@
import React, { ChangeEvent } from 'react';
import { mount } from 'enzyme';
import { ThresholdsEditor, Props } from './ThresholdsEditor';
import { colors } from '../../utils';
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
@@ -31,7 +32,7 @@ describe('Initialization', () => {
it('should add a base threshold if missing', () => {
const { instance } = setup();
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
});
});
@@ -41,7 +42,7 @@ describe('Add threshold', () => {
instance.onAddThreshold(0);
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
});
it('should add threshold', () => {
@@ -50,41 +51,41 @@ describe('Add threshold', () => {
instance.onAddThreshold(1);
expect(instance.state.thresholds).toEqual([
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: colors[0] },
{ index: 1, value: 50, color: colors[2] },
]);
});
it('should add another threshold above a first', () => {
const { instance } = setup({
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }],
});
instance.onAddThreshold(2);
expect(instance.state.thresholds).toEqual([
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 0, value: -Infinity, color: colors[0] },
{ index: 1, value: 50, color: colors[2] },
{ index: 2, value: 75, color: colors[3] },
]);
});
it('should add another threshold between first and second index', () => {
const { instance } = setup({
thresholds: [
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 0, value: -Infinity, color: colors[0] },
{ index: 1, value: 50, color: colors[2] },
{ index: 2, value: 75, color: colors[3] },
],
});
instance.onAddThreshold(2);
expect(instance.state.thresholds).toEqual([
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 62.5, color: '#EF843C' },
{ index: 3, value: 75, color: '#6ED0E0' },
{ index: 0, value: -Infinity, color: colors[0] },
{ index: 1, value: 50, color: colors[2] },
{ index: 2, value: 62.5, color: colors[4] },
{ index: 3, value: 75, color: colors[3] },
]);
});
});
@@ -161,7 +162,7 @@ describe('change threshold value', () => {
});
describe('on blur threshold value', () => {
it.only('should resort rows and update indexes', () => {
it('should resort rows and update indexes', () => {
const { instance } = setup();
const thresholds = [
{ index: 0, value: -Infinity, color: '#7EB26D' },
@@ -3,8 +3,8 @@ import { Threshold } from '../../types';
import { ColorPicker } from '..';
import { PanelOptionsGroup } from '..';
import { colors } from '../../utils';
import { ThemeContext } from '../../themes/ThemeContext';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { ThemeContext } from '../../themes';
import { getColorFromHexRgbOrName } from '../../utils';
export interface Props {
thresholds: Threshold[];
@@ -166,7 +166,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
<div className="thresholds-row-input-inner-color">
{threshold.color && (
<div className="thresholds-row-input-inner-color-colorpicker">
<ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
<ColorPicker
color={threshold.color}
onChange={color => this.onChangeThresholdColor(threshold, color)}
enableNamedColors={true}
/>
</div>
)}
</div>
@@ -1,7 +1,450 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render with base threshold 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
<ThresholdsEditor
onChange={
[MockFunction] {
"calls": Array [
Array [
Array [
Object {
"color": "#7EB26D",
"index": 0,
"value": -Infinity,
},
],
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
}
}
thresholds={Array []}
>
<Component
title="Thresholds"
>
<div
className="panel-options-group"
>
<div
className="panel-options-group__header"
>
<span
className="panel-options-group__title"
>
Thresholds
</span>
</div>
<div
className="panel-options-group__body"
>
<div
className="thresholds"
>
<div
className="thresholds-row"
key="0-0"
>
<div
className="thresholds-row-add-button"
onClick={[Function]}
>
<i
className="fa fa-plus"
/>
</div>
<div
className="thresholds-row-color-indicator"
style={
Object {
"backgroundColor": "#7EB26D",
}
}
/>
<div
className="thresholds-row-input"
>
<div
className="thresholds-row-input-inner"
>
<span
className="thresholds-row-input-inner-arrow"
/>
<div
className="thresholds-row-input-inner-color"
>
<div
className="thresholds-row-input-inner-color-colorpicker"
>
<WithTheme(ColorPicker)
color="#7EB26D"
enableNamedColors={true}
onChange={[Function]}
>
<ColorPicker
color="#7EB26D"
enableNamedColors={true}
onChange={[Function]}
theme={
Object {
"background": Object {
"dropdown": "#1f1f20",
"scrollbar": "#343436",
"scrollbar2": "#343436",
},
"border": Object {
"radius": Object {
"lg": "5px",
"md": "3px",
"sm": "2px",
},
"width": Object {
"sm": "1px",
},
},
"breakpoints": Object {
"lg": "992px",
"md": "768px",
"sm": "544px",
"xl": "1200px",
"xs": "0",
},
"colors": Object {
"black": "#000000",
"blue": "#33b5e5",
"blueBase": "#3274d9",
"blueFaint": "#041126",
"blueLight": "#5794f2",
"blueShade": "#1f60c4",
"body": "#d8d9da",
"bodyBg": "#161719",
"brandDanger": "#e02f44",
"brandPrimary": "#eb7b18",
"brandSuccess": "#299c46",
"brandWarning": "#eb7b18",
"critical": "#e02f44",
"dark1": "#141414",
"dark10": "#424345",
"dark2": "#161719",
"dark3": "#1f1f20",
"dark4": "#212124",
"dark5": "#222426",
"dark6": "#262628",
"dark7": "#292a2d",
"dark8": "#2f2f32",
"dark9": "#343436",
"gray1": "#555555",
"gray2": "#8e8e8e",
"gray3": "#b3b3b3",
"gray4": "#d8d9da",
"gray5": "#ececec",
"gray6": "#f4f5f8",
"gray7": "#fbfbfb",
"grayBlue": "#212327",
"greenBase": "#299c46",
"greenShade": "#23843b",
"headingColor": "#e3e3e3",
"inputBlack": "#09090b",
"link": "#e3e3e3",
"linkDisabled": "#e3e3e3",
"linkExternal": "#33b5e5",
"linkHover": "#ffffff",
"online": "#299c46",
"orange": "#eb7b18",
"pageBg": "#161719",
"purple": "#9933cc",
"queryGreen": "#74e680",
"queryKeyword": "#66d9ef",
"queryOrange": "#eb7b18",
"queryPurple": "#fe85fc",
"queryRed": "#e02f44",
"red": "#d44a3a",
"redBase": "#e02f44",
"redShade": "#c4162a",
"text": "#d8d9da",
"textEmphasis": "#ececec",
"textFaint": "#222426",
"textStrong": "#ffffff",
"textWeak": "#8e8e8e",
"variable": "#32d1df",
"warn": "#f79520",
"white": "#ffffff",
"yellow": "#ecbb13",
},
"name": "Grafana Dark",
"panelPadding": Object {
"horizontal": 10,
"vertical": 5,
},
"spacing": Object {
"d": "14px",
"gutter": "30px",
"lg": "24px",
"md": "16px",
"sm": "8px",
"xl": "32px",
"xs": "4px",
"xxs": "2px",
},
"type": "dark",
"typography": Object {
"fontFamily": Object {
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
"sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
},
"heading": Object {
"h1": "28px",
"h2": "24px",
"h3": "21px",
"h4": "18px",
"h5": "16px",
"h6": "14px",
},
"lineHeight": Object {
"lg": 1.5,
"md": 1.3333333333333333,
"sm": 1.1,
"xs": 1,
},
"size": Object {
"base": "13px",
"lg": "18px",
"md": "14px",
"root": "14px",
"sm": "12px",
"xs": "10px",
},
"weight": Object {
"light": 300,
"regular": 400,
"semibold": 500,
},
},
}
}
>
<PopperController
content={
<ColorPickerPopover
color="#7EB26D"
enableNamedColors={true}
onChange={[Function]}
theme={
Object {
"background": Object {
"dropdown": "#1f1f20",
"scrollbar": "#343436",
"scrollbar2": "#343436",
},
"border": Object {
"radius": Object {
"lg": "5px",
"md": "3px",
"sm": "2px",
},
"width": Object {
"sm": "1px",
},
},
"breakpoints": Object {
"lg": "992px",
"md": "768px",
"sm": "544px",
"xl": "1200px",
"xs": "0",
},
"colors": Object {
"black": "#000000",
"blue": "#33b5e5",
"blueBase": "#3274d9",
"blueFaint": "#041126",
"blueLight": "#5794f2",
"blueShade": "#1f60c4",
"body": "#d8d9da",
"bodyBg": "#161719",
"brandDanger": "#e02f44",
"brandPrimary": "#eb7b18",
"brandSuccess": "#299c46",
"brandWarning": "#eb7b18",
"critical": "#e02f44",
"dark1": "#141414",
"dark10": "#424345",
"dark2": "#161719",
"dark3": "#1f1f20",
"dark4": "#212124",
"dark5": "#222426",
"dark6": "#262628",
"dark7": "#292a2d",
"dark8": "#2f2f32",
"dark9": "#343436",
"gray1": "#555555",
"gray2": "#8e8e8e",
"gray3": "#b3b3b3",
"gray4": "#d8d9da",
"gray5": "#ececec",
"gray6": "#f4f5f8",
"gray7": "#fbfbfb",
"grayBlue": "#212327",
"greenBase": "#299c46",
"greenShade": "#23843b",
"headingColor": "#e3e3e3",
"inputBlack": "#09090b",
"link": "#e3e3e3",
"linkDisabled": "#e3e3e3",
"linkExternal": "#33b5e5",
"linkHover": "#ffffff",
"online": "#299c46",
"orange": "#eb7b18",
"pageBg": "#161719",
"purple": "#9933cc",
"queryGreen": "#74e680",
"queryKeyword": "#66d9ef",
"queryOrange": "#eb7b18",
"queryPurple": "#fe85fc",
"queryRed": "#e02f44",
"red": "#d44a3a",
"redBase": "#e02f44",
"redShade": "#c4162a",
"text": "#d8d9da",
"textEmphasis": "#ececec",
"textFaint": "#222426",
"textStrong": "#ffffff",
"textWeak": "#8e8e8e",
"variable": "#32d1df",
"warn": "#f79520",
"white": "#ffffff",
"yellow": "#ecbb13",
},
"name": "Grafana Dark",
"panelPadding": Object {
"horizontal": 10,
"vertical": 5,
},
"spacing": Object {
"d": "14px",
"gutter": "30px",
"lg": "24px",
"md": "16px",
"sm": "8px",
"xl": "32px",
"xs": "4px",
"xxs": "2px",
},
"type": "dark",
"typography": Object {
"fontFamily": Object {
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
"sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
},
"heading": Object {
"h1": "28px",
"h2": "24px",
"h3": "21px",
"h4": "18px",
"h5": "16px",
"h6": "14px",
},
"lineHeight": Object {
"lg": 1.5,
"md": 1.3333333333333333,
"sm": 1.1,
"xs": 1,
},
"size": Object {
"base": "13px",
"lg": "18px",
"md": "14px",
"root": "14px",
"sm": "12px",
"xs": "10px",
},
"weight": Object {
"light": 300,
"regular": 400,
"semibold": 500,
},
},
}
}
/>
}
hideAfter={300}
>
<ForwardRef(ColorPickerTrigger)
color="#7EB26D"
onClick={[Function]}
onMouseLeave={[Function]}
>
<div
onClick={[Function]}
onMouseLeave={[Function]}
style={
Object {
"background": "inherit",
"border": "none",
"borderRadius": 10,
"color": "inherit",
"cursor": "pointer",
"overflow": "hidden",
"padding": 0,
}
}
>
<div
style={
Object {
"backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)",
"border": "none",
"float": "left",
"height": 15,
"margin": 0,
"position": "relative",
"width": 15,
"zIndex": 0,
}
}
>
<div
style={
Object {
"backgroundColor": "#7EB26D",
"bottom": 0,
"display": "block",
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
/>
</div>
</div>
</ForwardRef(ColorPickerTrigger)>
</PopperController>
</ColorPicker>
</WithTheme(ColorPicker)>
</div>
</div>
<div
className="thresholds-row-input-inner-value"
>
<input
readOnly={true}
type="text"
value="Base"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Component>
</ThresholdsEditor>
`;
@@ -25,6 +25,7 @@ export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
export { Switch } from './Switch/Switch';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { UnitPicker } from './UnitPicker/UnitPicker';
export { Input, InputStatus } from './Input/Input';
// Visualizations
export { Gauge } from './Gauge/Gauge';
@@ -120,7 +120,7 @@ $headings-line-height: ${theme.typography.lineHeight.sm} !default;
$border-width: ${theme.border.width.sm} !default;
$border-radius: ${theme.border.radius.md} !default;
$border-radius-lg: ${theme.border.radius.lg}!default;
$border-radius-lg: ${theme.border.radius.lg} !default;
$border-radius-sm: ${theme.border.radius.sm} !default;
// Page
@@ -191,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
$btn-padding-x-xl: 21px !default;
$btn-padding-y-xl: 11px !default;
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
// sidemenu
+3 -17
View File
@@ -51,27 +51,13 @@ export enum NullValueMode {
export type TimeSeriesVMs = TimeSeriesVM[];
export interface Column {
text: string;
title?: string;
type?: string;
sort?: boolean;
desc?: boolean;
filterable?: boolean;
text: string; // The column name
type?: 'time' | 'number' | 'string' | 'object'; // not used anywhere? can we remove?
filterable?: boolean; // currently only set by elasticsearch, and used in the table panel
unit?: string;
}
export interface TableData {
columns: Column[];
rows: any[];
type: string;
columnMap: any;
}
export type SingleStatValue = number | string | null;
/*
* So we can add meta info like tags & series name
*/
export interface SingleStatValueInfo {
value: SingleStatValue;
}
+1
View File
@@ -5,3 +5,4 @@ export * from './plugin';
export * from './datasource';
export * from './theme';
export * from './threshold';
export * from './input';
+18 -11
View File
@@ -1,12 +1,12 @@
import { ComponentClass } from 'react';
import { TimeSeries, LoadingState, TableData } from './data';
import { LoadingState, TableData } from './data';
import { TimeRange } from './time';
import { ScopedVars } from './datasource';
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
export interface PanelProps<T = any> {
panelData: PanelData;
data?: TableData[];
timeRange: TimeRange;
loading: LoadingState;
options: T;
@@ -16,23 +16,26 @@ export interface PanelProps<T = any> {
replaceVariables: InterpolateFunction;
}
export interface PanelData {
timeSeries?: TimeSeries[];
tableData?: TableData;
}
export interface PanelEditorProps<T = any> {
options: T;
onOptionsChange: (options: T) => void;
}
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
/**
* Called before a panel is initalized
*/
export type PanelTypeChangedHook<TOptions = any> = (
options: Partial<TOptions>,
prevPluginId?: string,
prevOptions?: any
) => Partial<TOptions>;
export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
defaults?: TOptions;
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
panelTypeChangedHook?: PanelTypeChangedHook<TOptions>;
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
this.panel = panel;
@@ -46,8 +49,12 @@ export class ReactPanelPlugin<TOptions = any> {
this.defaults = defaults;
}
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
this.preserveOptions = handler;
/**
* Called when the visualization changes.
* Lets you keep whatever settings made sense in the previous panel
*/
setPanelTypeChangedHook(v: PanelTypeChangedHook<TOptions>) {
this.panelTypeChangedHook = v;
}
}
+1
View File
@@ -91,6 +91,7 @@ export interface PluginMeta {
includes: PluginInclude[];
// Datasource-specific
builtIn?: boolean;
metrics?: boolean;
tables?: boolean;
logs?: boolean;
@@ -2,7 +2,6 @@
exports[`processTableData basic processing should generate a header and fix widths 1`] = `
Object {
"columnMap": Object {},
"columns": Array [
Object {
"text": "Column 1",
@@ -31,13 +30,11 @@ Object {
null,
],
],
"type": "table",
}
`;
exports[`processTableData basic processing should read header and two rows 1`] = `
Object {
"columnMap": Object {},
"columns": Array [
Object {
"text": "a",
@@ -61,6 +58,5 @@ Object {
6,
],
],
"type": "table",
}
`;
@@ -0,0 +1,157 @@
import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, DisplayValue } from './displayValue';
import { MappingType, ValueMapping } from '../types/panel';
function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) {
processors.forEach(processor => {
const value = processor(input);
expect(value.text).toEqual(match.text);
if (match.hasOwnProperty('numeric')) {
expect(value.numeric).toEqual(match.numeric);
}
});
}
describe('Process simple display values', () => {
// Don't test float values here since the decimal formatting changes
const processors = [
// Without options, this shortcuts to a much easier implementation
getDisplayProcessor(),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ color: '#FFF' }),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ unit: 'locale' }),
];
it('support null', () => {
assertSame(null, processors, { text: '', numeric: NaN });
});
it('support undefined', () => {
assertSame(undefined, processors, { text: '', numeric: NaN });
});
it('support NaN', () => {
assertSame(NaN, processors, { text: 'NaN', numeric: NaN });
});
it('Integer', () => {
assertSame(3, processors, { text: '3', numeric: 3 });
});
it('Text to number', () => {
assertSame('3', processors, { text: '3', numeric: 3 });
});
it('Simple String', () => {
assertSame('hello', processors, { text: 'hello', numeric: NaN });
});
it('empty array', () => {
assertSame([], processors, { text: '', numeric: NaN });
});
it('array of text', () => {
assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN });
});
it('array of numbers', () => {
assertSame([1, 2, 3], processors, { text: '1,2,3', numeric: NaN });
});
it('empty object', () => {
assertSame({}, processors, { text: '[object Object]', numeric: NaN });
});
it('boolean true', () => {
assertSame(true, processors, { text: 'true', numeric: 1 });
});
it('boolean false', () => {
assertSame(false, processors, { text: 'false', numeric: 0 });
});
});
describe('Processor with more configs', () => {
it('support prefix & suffix', () => {
const processor = getDisplayProcessor({
prefix: 'AA_',
suffix: '_ZZ',
});
expect(processor('XXX').text).toEqual('AA_XXX_ZZ');
});
});
describe('Get color from threshold', () => {
it('should get first threshold color when only one threshold', () => {
const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }];
expect(getColorFromThreshold(49, thresholds)).toEqual('#7EB26D');
});
it('should get the threshold color if value is same as a threshold', () => {
const thresholds = [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
];
expect(getColorFromThreshold(50, thresholds)).toEqual('#EAB839');
});
it('should get the nearest threshold color between thresholds', () => {
const thresholds = [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
];
expect(getColorFromThreshold(55, thresholds)).toEqual('#EAB839');
});
});
describe('Format value', () => {
it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = [];
const value = 'N/A';
const instance = getDisplayProcessor({ mappings: valueMappings });
const result = instance(value);
expect(result.text).toEqual('N/A');
});
it('should return formatted value if there are no value mappings', () => {
const valueMappings: ValueMapping[] = [];
const value = '6';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const result = instance(value);
expect(result.text).toEqual('6.0');
});
it('should return formatted value if there are no matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const result = instance(value);
expect(result.text).toEqual('10.0');
});
it('should return mapped value if there are matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '11';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
expect(instance(value).text).toEqual('1-20');
});
});
@@ -0,0 +1,145 @@
import { ValueMapping, Threshold } from '../types';
import _ from 'lodash';
import { getValueFormat, DecimalCount } from './valueFormats/valueFormats';
import { getMappedValue } from './valueMappings';
import { GrafanaTheme, GrafanaThemeType } from '../types';
import { getColorFromHexRgbOrName } from './namedColorsPalette';
import moment from 'moment';
export interface DisplayValue {
text: string; // Show in the UI
numeric: number; // Use isNaN to check if it is a real number
color?: string; // color based on configs or Threshold
}
export interface DisplayValueOptions {
unit?: string;
decimals?: DecimalCount;
scaledDecimals?: DecimalCount;
dateFormat?: string; // If set try to convert numbers to date
color?: string;
mappings?: ValueMapping[];
thresholds?: Threshold[];
prefix?: string;
suffix?: string;
// Alternative to empty string
noValue?: string;
// Context
isUtc?: boolean;
theme?: GrafanaTheme; // Will pick 'dark' if not defined
}
export type DisplayProcessor = (value: any) => DisplayValue;
export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor {
if (options && !_.isEmpty(options)) {
const formatFunc = getValueFormat(options.unit || 'none');
return (value: any) => {
const { prefix, suffix, mappings, thresholds, theme } = options;
let color = options.color;
let text = _.toString(value);
let numeric = toNumber(value);
let shouldFormat = true;
if (mappings && mappings.length > 0) {
const mappedValue = getMappedValue(mappings, value);
if (mappedValue) {
text = mappedValue.text;
const v = toNumber(text);
if (!isNaN(v)) {
numeric = v;
}
shouldFormat = false;
}
}
if (options.dateFormat) {
const date = toMoment(value, numeric, options.dateFormat);
if (date.isValid()) {
text = date.format(options.dateFormat);
shouldFormat = false;
}
}
if (!isNaN(numeric)) {
if (shouldFormat && !_.isBoolean(value)) {
text = formatFunc(numeric, options.decimals, options.scaledDecimals, options.isUtc);
}
if (thresholds && thresholds.length > 0) {
color = getColorFromThreshold(numeric, thresholds, theme);
}
}
if (!text) {
text = options.noValue ? options.noValue : '';
}
if (prefix) {
text = prefix + text;
}
if (suffix) {
text = text + suffix;
}
return { text, numeric, color };
};
}
return toStringProcessor;
}
function toMoment(value: any, numeric: number, format: string): moment.Moment {
if (!isNaN(numeric)) {
const v = moment(numeric);
if (v.isValid()) {
return v;
}
}
const v = moment(value, format);
if (v.isValid) {
return v;
}
return moment(value); // moment will try to parse the format
}
/** Will return any value as a number or NaN */
function toNumber(value: any): number {
if (typeof value === 'number') {
return value;
}
if (value === null || value === undefined || Array.isArray(value)) {
return NaN; // lodash calls them 0
}
if (typeof value === 'boolean') {
return value ? 1 : 0;
}
return _.toNumber(value);
}
function toStringProcessor(value: any): DisplayValue {
return { text: _.toString(value), numeric: toNumber(value) };
}
export function getColorFromThreshold(value: number, thresholds: Threshold[], theme?: GrafanaTheme): string {
const themeType = theme ? theme.type : GrafanaThemeType.Dark;
if (thresholds.length === 1) {
return getColorFromHexRgbOrName(thresholds[0].color, themeType);
}
const atThreshold = thresholds.filter(threshold => value === threshold.value)[0];
if (atThreshold) {
return getColorFromHexRgbOrName(atThreshold.color, themeType);
}
const belowThreshold = thresholds.filter(threshold => value > threshold.value);
if (belowThreshold.length > 0) {
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
return getColorFromHexRgbOrName(nearestThreshold.color, themeType);
}
// Use the first threshold as the default color
return getColorFromHexRgbOrName(thresholds[0].color, themeType);
}
+3 -1
View File
@@ -1,9 +1,11 @@
export * from './processTimeSeries';
export * from './singlestat';
export * from './processTableData';
export * from './valueFormats/valueFormats';
export * from './colors';
export * from './namedColorsPalette';
export * from './thresholds';
export * from './string';
export * from './displayValue';
export * from './deprecationWarning';
export { getMappedValue } from './valueMappings';
export * from './validate';
@@ -1,4 +1,4 @@
import { parseCSV } from './processTableData';
import { parseCSV, toTableData } from './processTableData';
describe('processTableData', () => {
describe('basic processing', () => {
@@ -18,3 +18,41 @@ describe('processTableData', () => {
});
});
});
describe('toTableData', () => {
it('converts timeseries to table skipping nulls', () => {
const input1 = {
target: 'Field Name',
datapoints: [[100, 1], [200, 2]],
};
const input2 = {
// without target
target: '',
datapoints: [[100, 1], [200, 2]],
};
const data = toTableData([null, input1, input2, null, null]);
expect(data.length).toBe(2);
expect(data[0].columns[0].text).toBe(input1.target);
expect(data[0].rows).toBe(input1.datapoints);
// Default name
expect(data[1].columns[0].text).toEqual('Value');
});
it('keeps tableData unchanged', () => {
const input = {
columns: [{ text: 'A' }, { text: 'B' }, { text: 'C' }],
rows: [[100, 'A', 1], [200, 'B', 2], [300, 'C', 3]],
};
const data = toTableData([null, input, null, null]);
expect(data.length).toBe(1);
expect(data[0]).toBe(input);
});
it('supports null values OK', () => {
expect(toTableData([null, null, null, null])).toEqual([]);
expect(toTableData(undefined)).toEqual([]);
expect(toTableData((null as unknown) as any[])).toEqual([]);
expect(toTableData([])).toEqual([]);
});
});
@@ -3,7 +3,7 @@ import isNumber from 'lodash/isNumber';
import Papa, { ParseError, ParseMeta } from 'papaparse';
// Types
import { TableData, Column } from '../types';
import { TableData, Column, TimeSeries } from '../types';
// Subset of all parse options
export interface TableParseOptions {
@@ -70,8 +70,6 @@ export function matchRowSizes(table: TableData): TableData {
return {
columns,
rows: fixedRows,
type: table.type,
columnMap: table.columnMap,
};
}
@@ -118,8 +116,6 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
return {
columns: [],
rows: [],
type: 'table',
columnMap: {},
};
}
@@ -130,11 +126,48 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
return matchRowSizes({
columns: makeColumns(header),
rows: results.data,
type: 'table',
columnMap: {},
});
}
function convertTimeSeriesToTableData(timeSeries: TimeSeries): TableData {
return {
columns: [
{
text: timeSeries.target || 'Value',
unit: timeSeries.unit,
},
{
text: 'Time',
type: 'time',
unit: 'dateTimeAsIso',
},
],
rows: timeSeries.datapoints,
};
}
export const isTableData = (data: any): data is TableData => data && data.hasOwnProperty('columns');
export const toTableData = (results?: any[]): TableData[] => {
if (!results) {
return [];
}
return results
.filter(d => !!d)
.map(data => {
if (data.hasOwnProperty('columns')) {
return data as TableData;
}
if (data.hasOwnProperty('datapoints')) {
return convertTimeSeriesToTableData(data);
}
// TODO, try to convert JSON to table?
console.warn('Can not convert', data);
throw new Error('Unsupported data format');
});
};
export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
if (isNumber(sortIndex)) {
const copy = {
@@ -4,17 +4,36 @@ import isNumber from 'lodash/isNumber';
import { colors } from './colors';
// Types
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
import { TimeSeriesVMs, NullValueMode, TimeSeriesValue, TableData } from '../types';
interface Options {
timeSeries: TimeSeries[];
data: TableData[];
xColumn?: number; // Time (or null to guess)
yColumn?: number; // Value (or null to guess)
nullValueMode: NullValueMode;
}
export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
const vmSeries = timeSeries.map((item, index) => {
// NOTE: this should move to processTableData.ts
// I left it as is so the merge changes are more clear.
export function processTimeSeries({ data, xColumn, yColumn, nullValueMode }: Options): TimeSeriesVMs {
const vmSeries = data.map((item, index) => {
if (!isNumber(xColumn)) {
xColumn = 1; // Default timeseries colum. TODO, find first time field!
}
if (!isNumber(yColumn)) {
yColumn = 0; // TODO, find first non-time field
}
// TODO? either % or throw error?
if (xColumn >= item.columns.length) {
throw new Error('invalid colum: ' + xColumn);
}
if (yColumn >= item.columns.length) {
throw new Error('invalid colum: ' + yColumn);
}
const colorIndex = index % colors.length;
const label = item.target;
const label = item.columns[yColumn].text;
const result = [];
// stat defaults
@@ -42,9 +61,9 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
let previousValue = 0;
let previousDeltaUp = true;
for (let i = 0; i < item.datapoints.length; i++) {
currentValue = item.datapoints[i][0];
currentTime = item.datapoints[i][1];
for (let i = 0; i < item.rows.length; i++) {
currentValue = item.rows[i][yColumn];
currentTime = item.rows[i][xColumn];
if (typeof currentTime !== 'number') {
continue;
@@ -95,7 +114,7 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
if (previousValue > currentValue) {
// counter reset
previousDeltaUp = false;
if (i === item.datapoints.length - 1) {
if (i === item.rows.length - 1) {
// reset on last
delta += currentValue;
}
@@ -1,33 +0,0 @@
import { PanelData, NullValueMode, SingleStatValueInfo } from '../types';
import { processTimeSeries } from './processTimeSeries';
export interface SingleStatProcessingOptions {
panelData: PanelData;
stat: string;
}
//
// This is a temporary thing, waiting for a better data model and maybe unification between time series & table data
//
export function processSingleStatPanelData(options: SingleStatProcessingOptions): SingleStatValueInfo[] {
const { panelData, stat } = options;
if (panelData.timeSeries) {
const timeSeries = processTimeSeries({
timeSeries: panelData.timeSeries,
nullValueMode: NullValueMode.Null,
});
return timeSeries.map((series, index) => {
const value = stat !== 'name' ? series.stats[stat] : series.label;
return {
value: value,
};
});
} else if (panelData.tableData) {
throw { message: 'Panel data not supported' };
}
return [];
}
+24
View File
@@ -0,0 +1,24 @@
import { ValidationRule, ValidationEvents } from '../types/input';
export enum EventsWithValidation {
onBlur = 'onBlur',
onFocus = 'onFocus',
onChange = 'onChange',
}
export const validate = (value: string, validationRules: ValidationRule[]) => {
const errors = validationRules.reduce(
(acc, currRule) => {
if (!currRule.rule(value)) {
return acc.concat(currRule.errorMessage);
}
return acc;
},
[] as string[]
);
return errors.length > 0 ? errors : null;
};
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => {
return validationEvents && validationEvents[event];
};
+23
View File
@@ -110,3 +110,26 @@ func AdminDeleteUser(c *m.ReqContext) {
c.JsonOK("User deleted")
}
// POST /api/admin/users/:id/logout
func (server *HTTPServer) AdminLogoutUser(c *m.ReqContext) Response {
userID := c.ParamsInt64(":id")
if c.UserId == userID {
return Error(400, "You cannot logout yourself", nil)
}
return server.logoutUserFromAllDevicesInternal(userID)
}
// GET /api/admin/users/:id/auth-tokens
func (server *HTTPServer) AdminGetUserAuthTokens(c *m.ReqContext) Response {
userID := c.ParamsInt64(":id")
return server.getUserAuthTokensInternal(c, userID)
}
// POST /api/admin/users/:id/revoke-auth-token
func (server *HTTPServer) AdminRevokeUserAuthToken(c *m.ReqContext, cmd m.RevokeAuthTokenCmd) Response {
userID := c.ParamsInt64(":id")
return server.revokeUserAuthTokenInternal(c, userID, cmd)
}
+138
View File
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
. "github.com/smartystreets/goconvey/convey"
)
@@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 400)
})
})
Convey("When a server admin attempts to logout himself from all devices", t, func() {
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
cmd.Result = &m.User{Id: TestUserID}
return nil
})
adminLogoutUserScenario("Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 400)
})
})
Convey("When a server admin attempts to logout a non-existing user from all devices", t, func() {
userId := int64(0)
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
userId = cmd.Id
return m.ErrUserNotFound
})
adminLogoutUserScenario("Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
So(userId, ShouldEqual, 200)
})
})
Convey("When a server admin attempts to revoke an auth token for a non-existing user", t, func() {
userId := int64(0)
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
userId = cmd.Id
return m.ErrUserNotFound
})
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
adminRevokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
So(userId, ShouldEqual, 200)
})
})
Convey("When a server admin gets auth tokens for a non-existing user", t, func() {
userId := int64(0)
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
userId = cmd.Id
return m.ErrUserNotFound
})
adminGetUserAuthTokensScenario("Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) {
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
So(userId, ShouldEqual, 200)
})
})
}
func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) {
@@ -48,3 +105,84 @@ func putAdminScenario(desc string, url string, routePattern string, role m.RoleT
fn(sc)
})
}
func adminLogoutUserScenario(desc string, url string, routePattern string, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: auth.NewFakeUserAuthTokenService(),
}
sc := setupScenarioContext(url)
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
return hs.AdminLogoutUser(c)
})
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}
func adminRevokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
}
sc := setupScenarioContext(url)
sc.userAuthTokenService = fakeAuthTokenService
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
return hs.AdminRevokeUserAuthToken(c, cmd)
})
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}
func adminGetUserAuthTokensScenario(desc string, url string, routePattern string, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
}
sc := setupScenarioContext(url)
sc.userAuthTokenService = fakeAuthTokenService
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
return hs.AdminGetUserAuthTokens(c)
})
sc.m.Get(routePattern, sc.defaultHandler)
fn(sc)
})
}
+21 -12
View File
@@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() {
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
reqEditorRole := middleware.ReqEditorRole
reqOrgAdmin := middleware.ReqOrgAdmin
reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin)
redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
quota := middleware.Quota(hs.QuotaService)
@@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/org/users", reqOrgAdmin, hs.Index)
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
r.Get("/org/users/invite", reqOrgAdmin, hs.Index)
r.Get("/org/teams", reqOrgAdmin, hs.Index)
r.Get("/org/teams/*", reqOrgAdmin, hs.Index)
r.Get("/org/teams", reqCanAccessTeams, hs.Index)
r.Get("/org/teams/*", reqCanAccessTeams, hs.Index)
r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
@@ -133,6 +134,9 @@ func (hs *HTTPServer) registerRoutes() {
userRoute.Get("/preferences", Wrap(GetUserPreferences))
userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences))
userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens))
userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken))
})
// users (admin permission required)
@@ -150,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() {
// team (admin permission required)
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID))
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID))
teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences))
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences))
}, reqOrgAdmin)
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember))
teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences))
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences))
}, reqCanAccessTeams)
// team without requirement of user to be org admin
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
teamsRoute.Get("/search", Wrap(SearchTeams))
teamsRoute.Get("/search", Wrap(hs.SearchTeams))
})
// org information available to all users.
@@ -262,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
folderRoute.Get("/", Wrap(GetFolders))
folderRoute.Get("/id/:id", Wrap(GetFolderByID))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", Wrap(GetFolderByUID))
@@ -375,6 +380,10 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
adminRoute.Get("/stats", AdminGetStats)
adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts))
adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser))
adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens))
adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
}, reqGrafanaAdmin)
// rendering
+3 -3
View File
@@ -48,18 +48,18 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) {
handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN))
}
}
handlers = append(handlers, AppPluginRoute(route, plugin.Id))
handlers = append(handlers, AppPluginRoute(route, plugin.Id, hs))
r.Route(url, route.Method, handlers...)
log.Debug("Plugins: Adding proxy route %s", url)
}
}
}
func AppPluginRoute(route *plugins.AppPluginRoute, appID string) macaron.Handler {
func AppPluginRoute(route *plugins.AppPluginRoute, appID string, hs *HTTPServer) macaron.Handler {
return func(c *m.ReqContext) {
path := c.Params("*")
proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID)
proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg)
proxy.Transport = pluginProxyTransport
proxy.ServeHTTP(c.Resp, c.Req.Request)
}
+9 -7
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"gopkg.in/macaron.v1"
. "github.com/smartystreets/goconvey/convey"
@@ -94,13 +95,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
type scenarioContext struct {
m *macaron.Macaron
context *m.ReqContext
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
m *macaron.Macaron
context *m.ReqContext
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
userAuthTokenService *auth.FakeUserAuthTokenService
}
func (sc *scenarioContext) exec() {
+11 -1
View File
@@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
dash := cmd.GetDashboardModel()
if dash.Id == 0 && dash.Uid == "" {
newDashboard := dash.Id == 0 && dash.Uid == ""
if newDashboard {
limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
if err != nil {
return Error(500, "failed to get quota", err)
@@ -276,6 +277,15 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
return Error(500, "Failed to save dashboard", err)
}
if hs.Cfg.EditorsCanAdmin && newDashboard {
inFolder := cmd.FolderId > 0
err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
if err != nil {
hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
return Error(500, "Failed to make user admin of dashboard", err)
}
}
c.TimeRequest(metrics.M_Api_Dashboard_Save)
return JSON(200, util.DynMap{
"status": "success",
+2
View File
@@ -974,6 +974,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
hs := HTTPServer{
Bus: bus.GetBus(),
Cfg: setting.NewCfg(),
}
sc := setupScenarioContext(url)
@@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin
defer bus.ClearBusHandlers()
hs := HTTPServer{
Cfg: setting.NewCfg(),
Bus: bus.GetBus(),
}
+1 -1
View File
@@ -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
View File
@@ -0,0 +1,12 @@
package dtos
import "time"
type UserToken struct {
Id int64 `json:"id"`
IsActive bool `json:"isActive"`
ClientIp string `json:"clientIp"`
UserAgent string `json:"userAgent"`
CreatedAt time.Time `json:"createdAt"`
SeenAt time.Time `json:"seenAt"`
}
+8 -1
View File
@@ -54,13 +54,20 @@ func GetFolderByID(c *m.ReqContext) Response {
return JSON(200, toFolderDto(g, folder))
}
func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
err := s.CreateFolder(&cmd)
if err != nil {
return toFolderError(err)
}
if hs.Cfg.EditorsCanAdmin {
if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil {
hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
return Error(500, "Failed to make user admin of folder", err)
}
}
g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
return JSON(200, toFolderDto(g, cmd.Result))
}
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
@@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
hs := HTTPServer{
Bus: bus.GetBus(),
Cfg: setting.NewCfg(),
}
sc := setupScenarioContext(url)
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
return CreateFolder(c, cmd)
return hs.CreateFolder(c, cmd)
})
origNewFolderService := dashboards.NewFolderService
+1 -1
View File
@@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
"viewersCanEdit": setting.ViewersCanEdit,
"editorsCanOwn": hs.Cfg.EditorsCanOwn,
"editorsCanAdmin": hs.Cfg.EditorsCanAdmin,
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
"buildInfo": map[string]interface{}{
"version": setting.BuildVersion,
+21
View File
@@ -327,6 +327,27 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
})
}
if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin {
cfgNode := &dtos.NavLink{
Id: "cfg",
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "gicon gicon-cog",
Url: setting.AppSubUrl + "/org/teams",
Children: []*dtos.NavLink{
{
Text: "Teams",
Id: "teams",
Description: "Manage org groups",
Icon: "gicon gicon-team",
Url: setting.AppSubUrl + "/org/teams",
},
},
}
data.NavTree = append(data.NavTree, cfgNode)
}
data.NavTree = append(data.NavTree, &dtos.NavLink{
Text: "Help",
SubTitle: fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),
+7 -1
View File
@@ -34,13 +34,14 @@ type DataSourceProxy struct {
proxyPath string
route *plugins.AppPluginRoute
plugin *plugins.DataSourcePlugin
cfg *setting.Cfg
}
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string) *DataSourceProxy {
func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string, cfg *setting.Cfg) *DataSourceProxy {
targetURL, _ := url.Parse(ds.Url)
return &DataSourceProxy{
@@ -49,6 +50,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
ctx: ctx,
proxyPath: proxyPath,
targetUrl: targetURL,
cfg: cfg,
}
}
@@ -170,6 +172,10 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
req.Header.Add("Authorization", dsAuth)
}
if proxy.cfg.SendUserHeader && !proxy.ctx.SignedInUser.IsAnonymous {
req.Header.Add("X-Grafana-User", proxy.ctx.SignedInUser.Login)
}
// clear cookie header, except for whitelisted cookies
var keptCookies []*http.Cookie
if proxy.ds.JsonData != nil {
+73 -14
View File
@@ -81,7 +81,7 @@ func TestDSRouteRule(t *testing.T) {
}
Convey("When matching route path", func() {
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{})
proxy.route = plugin.Routes[0]
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
@@ -92,7 +92,7 @@ func TestDSRouteRule(t *testing.T) {
})
Convey("When matching route path and has dynamic url", func() {
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method")
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method", &setting.Cfg{})
proxy.route = plugin.Routes[3]
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
@@ -104,20 +104,20 @@ func TestDSRouteRule(t *testing.T) {
Convey("Validating request", func() {
Convey("plugin route with valid role", func() {
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{})
err := proxy.validateRequest()
So(err, ShouldBeNil)
})
Convey("plugin route with admin role and user is editor", func() {
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin")
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{})
err := proxy.validateRequest()
So(err, ShouldNotBeNil)
})
Convey("plugin route with admin role and user is admin", func() {
ctx.SignedInUser.OrgRole = m.ROLE_ADMIN
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin")
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{})
err := proxy.validateRequest()
So(err, ShouldBeNil)
})
@@ -186,7 +186,7 @@ func TestDSRouteRule(t *testing.T) {
So(err, ShouldBeNil)
client = newFakeHTTPClient(json)
proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
proxy1.route = plugin.Routes[0]
ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds)
@@ -200,7 +200,7 @@ func TestDSRouteRule(t *testing.T) {
req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
client = newFakeHTTPClient(json2)
proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2")
proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", &setting.Cfg{})
proxy2.route = plugin.Routes[1]
ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds)
@@ -215,7 +215,7 @@ func TestDSRouteRule(t *testing.T) {
req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
client = newFakeHTTPClient([]byte{})
proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
proxy3.route = plugin.Routes[0]
ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds)
@@ -236,7 +236,7 @@ func TestDSRouteRule(t *testing.T) {
ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "/render")
proxy := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{})
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
@@ -261,7 +261,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "")
proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
@@ -291,7 +291,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "")
proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -317,7 +317,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "")
proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -347,7 +347,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "")
proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -369,7 +369,7 @@ func TestDSRouteRule(t *testing.T) {
Url: "http://host/root/",
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/")
proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{})
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
req.Header.Add("Origin", "grafana.com")
req.Header.Add("Referer", "grafana.com")
@@ -388,9 +388,68 @@ func TestDSRouteRule(t *testing.T) {
So(req.Header.Get("X-Canary"), ShouldEqual, "stillthere")
})
})
Convey("When SendUserHeader config is enabled", func() {
req := getDatasourceProxiedRequest(
&m.ReqContext{
SignedInUser: &m.SignedInUser{
Login: "test_user",
},
},
&setting.Cfg{SendUserHeader: true},
)
Convey("Should add header with username", func() {
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
})
})
Convey("When SendUserHeader config is disabled", func() {
req := getDatasourceProxiedRequest(
&m.ReqContext{
SignedInUser: &m.SignedInUser{
Login: "test_user",
},
},
&setting.Cfg{SendUserHeader: false},
)
Convey("Should not add header with username", func() {
// Get will return empty string even if header is not set
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
})
})
Convey("When SendUserHeader config is enabled but user is anonymous", func() {
req := getDatasourceProxiedRequest(
&m.ReqContext{
SignedInUser: &m.SignedInUser{IsAnonymous: true},
},
&setting.Cfg{SendUserHeader: true},
)
Convey("Should not add header with username", func() {
// Get will return empty string even if header is not set
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
})
})
})
}
// getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
func getDatasourceProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
plugin := &plugins.DataSourcePlugin{}
ds := &m.DataSource{
Type: "custom",
Url: "http://host/root/",
}
proxy := NewDataSourceProxy(ds, plugin, ctx, "", cfg)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
proxy.getDirector()(req)
return req
}
type httpClientStub struct {
fakeBody []byte
}
+6 -1
View File
@@ -2,6 +2,7 @@ package pluginproxy
import (
"encoding/json"
"github.com/grafana/grafana/pkg/setting"
"net"
"net/http"
"net/http/httputil"
@@ -37,7 +38,7 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http.
return result, err
}
func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string) *httputil.ReverseProxy {
func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy {
targetURL, _ := url.Parse(route.Url)
director := func(req *http.Request) {
@@ -79,6 +80,10 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
req.Header.Add("X-Grafana-Context", string(ctxJson))
if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous {
req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login)
}
if len(route.Headers) > 0 {
headers, err := getHeaders(route, ctx.OrgId, appID)
if err != nil {
+56
View File
@@ -1,6 +1,7 @@
package pluginproxy
import (
"net/http"
"testing"
"github.com/grafana/grafana/pkg/bus"
@@ -44,4 +45,59 @@ func TestPluginProxy(t *testing.T) {
})
})
Convey("When SendUserHeader config is enabled", t, func() {
req := getPluginProxiedRequest(
&m.ReqContext{
SignedInUser: &m.SignedInUser{
Login: "test_user",
},
},
&setting.Cfg{SendUserHeader: true},
)
Convey("Should add header with username", func() {
// Get will return empty string even if header is not set
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
})
})
Convey("When SendUserHeader config is disabled", t, func() {
req := getPluginProxiedRequest(
&m.ReqContext{
SignedInUser: &m.SignedInUser{
Login: "test_user",
},
},
&setting.Cfg{SendUserHeader: false},
)
Convey("Should not add header with username", func() {
// Get will return empty string even if header is not set
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
})
})
Convey("When SendUserHeader config is enabled but user is anonymous", t, func() {
req := getPluginProxiedRequest(
&m.ReqContext{
SignedInUser: &m.SignedInUser{IsAnonymous: true},
},
&setting.Cfg{SendUserHeader: true},
)
Convey("Should not add header with username", func() {
// Get will return empty string even if header is not set
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
})
})
}
// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
route := &plugins.AppPluginRoute{}
proxy := NewApiPluginProxy(ctx, "", route, "", cfg)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
proxy.Director(req)
return req
}
+69 -17
View File
@@ -4,19 +4,38 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/teamguardian"
"github.com/grafana/grafana/pkg/util"
)
// POST /api/teams
func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
cmd.OrgId = c.OrgId
if err := bus.Dispatch(&cmd); err != nil {
if c.OrgRole == m.ROLE_VIEWER {
return Error(403, "Not allowed to create team.", nil)
}
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamNameTaken {
return Error(409, "Team name taken", err)
}
return Error(500, "Failed to create Team", err)
}
if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin {
addMemberCmd := m.AddTeamMemberCommand{
UserId: c.SignedInUser.UserId,
OrgId: cmd.OrgId,
TeamId: cmd.Result.Id,
Permission: m.PERMISSION_ADMIN,
}
if err := hs.Bus.Dispatch(&addMemberCmd); err != nil {
c.Logger.Error("Could not add creator to team.", "error", err)
}
}
return JSON(200, &util.DynMap{
"teamId": cmd.Result.Id,
"message": "Team created",
@@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
}
// PUT /api/teams/:teamId
func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
cmd.OrgId = c.OrgId
cmd.Id = c.ParamsInt64(":teamId")
if err := bus.Dispatch(&cmd); err != nil {
if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil {
return Error(403, "Not allowed to update team", err)
}
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamNameTaken {
return Error(400, "Team name taken", err)
}
@@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
}
// DELETE /api/teams/:teamId
func DeleteTeamByID(c *m.ReqContext) Response {
if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil {
func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response {
orgId := c.OrgId
teamId := c.ParamsInt64(":teamId")
user := c.SignedInUser
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil {
return Error(403, "Not allowed to delete team", err)
}
if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil {
if err == m.ErrTeamNotFound {
return Error(404, "Failed to delete Team. ID not found", nil)
}
return Error(500, "Failed to update Team", err)
return Error(500, "Failed to delete Team", err)
}
return Success("Team deleted")
}
// GET /api/teams/search
func SearchTeams(c *m.ReqContext) Response {
func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response {
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
@@ -59,12 +91,18 @@ func SearchTeams(c *m.ReqContext) Response {
page = 1
}
var userIdFilter int64
if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN {
userIdFilter = c.SignedInUser.UserId
}
query := m.SearchTeamsQuery{
OrgId: c.OrgId,
Query: c.Query("query"),
Name: c.Query("name"),
Page: page,
Limit: perPage,
OrgId: c.OrgId,
Query: c.Query("query"),
Name: c.Query("name"),
UserIdFilter: userIdFilter,
Page: page,
Limit: perPage,
}
if err := bus.Dispatch(&query); err != nil {
@@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response {
}
// GET /api/teams/:teamId/preferences
func GetTeamPreferences(c *m.ReqContext) Response {
return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"))
func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response {
teamId := c.ParamsInt64(":teamId")
orgId := c.OrgId
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to view team preferences.", err)
}
return getPreferencesFor(orgId, 0, teamId)
}
// PUT /api/teams/:teamId/preferences
func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd)
func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
teamId := c.ParamsInt64(":teamId")
orgId := c.OrgId
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to update team preferences.", err)
}
return updatePreferencesFor(orgId, 0, teamId, &dtoCmd)
}
+49 -5
View File
@@ -4,6 +4,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/teamguardian"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response {
}
// POST /api/teams/:teamId/members
func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
cmd.TeamId = c.ParamsInt64(":teamId")
func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
cmd.OrgId = c.OrgId
cmd.TeamId = c.ParamsInt64(":teamId")
if err := bus.Dispatch(&cmd); err != nil {
if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to add team member", err)
}
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamNotFound {
return Error(404, "Team not found", nil)
}
@@ -50,9 +55,48 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
})
}
// PUT /:teamId/members/:userId
func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response {
teamId := c.ParamsInt64(":teamId")
orgId := c.OrgId
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to update team member", err)
}
if c.OrgRole != m.ROLE_ADMIN {
cmd.ProtectLastAdmin = true
}
cmd.TeamId = teamId
cmd.UserId = c.ParamsInt64(":userId")
cmd.OrgId = orgId
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamMemberNotFound {
return Error(404, "Team member not found.", nil)
}
return Error(500, "Failed to update team member.", err)
}
return Success("Team member updated")
}
// DELETE /api/teams/:teamId/members/:userId
func RemoveTeamMember(c *m.ReqContext) Response {
if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response {
orgId := c.OrgId
teamId := c.ParamsInt64(":teamId")
userId := c.ParamsInt64(":userId")
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to remove team member", err)
}
protectLastAdmin := false
if c.OrgRole != m.ROLE_ADMIN {
protectLastAdmin = true
}
if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil {
if err == m.ErrTeamNotFound {
return Error(404, "Team not found", nil)
}
+8 -2
View File
@@ -3,6 +3,8 @@ package api
import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
@@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) {
TotalCount: 2,
}
hs := &HTTPServer{
Cfg: setting.NewCfg(),
}
Convey("When searching with no parameters", func() {
loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
var sentLimit int
@@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) {
return nil
})
sc.handlerFunc = SearchTeams
sc.handlerFunc = hs.SearchTeams
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
So(sentLimit, ShouldEqual, 1000)
@@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) {
return nil
})
sc.handlerFunc = SearchTeams
sc.handlerFunc = hs.SearchTeams
sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
So(sentLimit, ShouldEqual, 10)
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"time"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
)
// GET /api/user/auth-tokens
func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response {
return server.getUserAuthTokensInternal(c, c.UserId)
}
// POST /api/user/revoke-auth-token
func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response {
return server.revokeUserAuthTokenInternal(c, c.UserId, cmd)
}
func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response {
userQuery := models.GetUserByIdQuery{Id: userID}
if err := bus.Dispatch(&userQuery); err != nil {
if err == models.ErrUserNotFound {
return Error(404, "User not found", err)
}
return Error(500, "Could not read user from database", err)
}
err := server.AuthTokenService.RevokeAllUserTokens(userID)
if err != nil {
return Error(500, "Failed to logout user", err)
}
return JSON(200, util.DynMap{
"message": "User logged out",
})
}
func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response {
userQuery := models.GetUserByIdQuery{Id: userID}
if err := bus.Dispatch(&userQuery); err != nil {
if err == models.ErrUserNotFound {
return Error(404, "User not found", err)
}
return Error(500, "Failed to get user", err)
}
tokens, err := server.AuthTokenService.GetUserTokens(userID)
if err != nil {
return Error(500, "Failed to get user auth tokens", err)
}
result := []*dtos.UserToken{}
for _, token := range tokens {
isActive := false
if c.UserToken != nil && c.UserToken.Id == token.Id {
isActive = true
}
result = append(result, &dtos.UserToken{
Id: token.Id,
IsActive: isActive,
ClientIp: token.ClientIp,
UserAgent: token.UserAgent,
CreatedAt: time.Unix(token.CreatedAt, 0),
SeenAt: time.Unix(token.SeenAt, 0),
})
}
return JSON(200, result)
}
func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response {
userQuery := models.GetUserByIdQuery{Id: userID}
if err := bus.Dispatch(&userQuery); err != nil {
if err == models.ErrUserNotFound {
return Error(404, "User not found", err)
}
return Error(500, "Failed to get user", err)
}
token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId)
if err != nil {
if err == models.ErrUserTokenNotFound {
return Error(404, "User auth token not found", err)
}
return Error(500, "Failed to get user auth token", err)
}
if c.UserToken != nil && c.UserToken.Id == token.Id {
return Error(400, "Cannot revoke active user auth token", nil)
}
err = server.AuthTokenService.RevokeToken(token)
if err != nil {
if err == models.ErrUserTokenNotFound {
return Error(404, "User auth token not found", err)
}
return Error(500, "Failed to revoke user auth token", err)
}
return JSON(200, util.DynMap{
"message": "User auth token revoked",
})
}
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"testing"
"time"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
. "github.com/smartystreets/goconvey/convey"
)
func TestUserTokenApiEndpoint(t *testing.T) {
Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() {
userId := int64(0)
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
userId = cmd.Id
return m.ErrUserNotFound
})
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
So(userId, ShouldEqual, 200)
})
})
Convey("When current user gets auth tokens for a non-existing user", t, func() {
userId := int64(0)
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
userId = cmd.Id
return m.ErrUserNotFound
})
getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) {
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
So(userId, ShouldEqual, 200)
})
})
Convey("When logout an existing user from all devices", t, func() {
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
cmd.Result = &m.User{Id: 200}
return nil
})
logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
})
Convey("When logout a non-existing user from all devices", t, func() {
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
return m.ErrUserNotFound
})
logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
})
})
Convey("When revoke an auth token for a user", t, func() {
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
cmd.Result = &m.User{Id: 200}
return nil
})
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
token := &m.UserToken{Id: 1}
revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) {
sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
return &m.UserToken{Id: 2}, nil
}
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
})
Convey("When revoke the active auth token used by himself", t, func() {
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
cmd.Result = &m.User{Id: TestUserID}
return nil
})
cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
token := &m.UserToken{Id: 2}
revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) {
sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
return token, nil
}
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 400)
})
})
Convey("When gets auth tokens for a user", t, func() {
bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
cmd.Result = &m.User{Id: TestUserID}
return nil
})
currentToken := &m.UserToken{Id: 1}
getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) {
tokens := []*m.UserToken{
{
Id: 1,
ClientIp: "127.0.0.1",
UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
CreatedAt: time.Now().Unix(),
SeenAt: time.Now().Unix(),
},
{
Id: 2,
ClientIp: "127.0.0.2",
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
CreatedAt: time.Now().Unix(),
SeenAt: time.Now().Unix(),
},
}
sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) {
return tokens, nil
}
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
result := sc.ToJSON()
So(result.MustArray(), ShouldHaveLength, 2)
resultOne := result.GetIndex(0)
So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id)
So(resultOne.Get("isActive").MustBool(), ShouldBeTrue)
So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1")
So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36")
So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339))
So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339))
resultTwo := result.GetIndex(1)
So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id)
So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse)
So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2")
So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1")
So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339))
})
})
}
func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
}
sc := setupScenarioContext(url)
sc.userAuthTokenService = fakeAuthTokenService
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = userId
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
return hs.RevokeUserAuthToken(c, cmd)
})
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}
func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
}
sc := setupScenarioContext(url)
sc.userAuthTokenService = fakeAuthTokenService
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = userId
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
return hs.GetUserAuthTokens(c)
})
sc.m.Get(routePattern, sc.defaultHandler)
fn(sc)
})
}
func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: auth.NewFakeUserAuthTokenService(),
}
sc := setupScenarioContext("/")
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
return hs.logoutUserFromAllDevicesInternal(userId)
})
sc.m.Post("/", sc.defaultHandler)
fn(sc)
})
}
func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
}
sc := setupScenarioContext("/")
sc.userAuthTokenService = fakeAuthTokenService
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
sc.context.UserToken = token
return hs.revokeUserAuthTokenInternal(c, userId, cmd)
})
sc.m.Post("/", sc.defaultHandler)
fn(sc)
})
}
func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
}
sc := setupScenarioContext("/")
sc.userAuthTokenService = fakeAuthTokenService
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = m.ROLE_ADMIN
sc.context.UserToken = token
return hs.getUserAuthTokensInternal(c, TestUserID)
})
sc.m.Get("/", sc.defaultHandler)
fn(sc)
})
}
+1
View File
@@ -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
View File
@@ -0,0 +1,126 @@
package remotecache
import (
"context"
"time"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
var getTime = time.Now
const databaseCacheType = "database"
type databaseCache struct {
SQLStore *sqlstore.SqlStore
log log.Logger
}
func newDatabaseCache(sqlstore *sqlstore.SqlStore) *databaseCache {
dc := &databaseCache{
SQLStore: sqlstore,
log: log.New("remotecache.database"),
}
return dc
}
func (dc *databaseCache) Run(ctx context.Context) error {
ticker := time.NewTicker(time.Minute * 10)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
dc.internalRunGC()
}
}
}
func (dc *databaseCache) internalRunGC() {
now := getTime().Unix()
sql := `DELETE FROM cache_data WHERE (? - created_at) >= expires AND expires <> 0`
_, err := dc.SQLStore.NewSession().Exec(sql, now)
if err != nil {
dc.log.Error("failed to run garbage collect", "error", err)
}
}
func (dc *databaseCache) Get(key string) (interface{}, error) {
cacheHit := CacheData{}
session := dc.SQLStore.NewSession()
defer session.Close()
exist, err := session.Where("cache_key= ?", key).Get(&cacheHit)
if err != nil {
return nil, err
}
if !exist {
return nil, ErrCacheItemNotFound
}
if cacheHit.Expires > 0 {
existedButExpired := getTime().Unix()-cacheHit.CreatedAt >= cacheHit.Expires
if existedButExpired {
_ = dc.Delete(key) //ignore this error since we will return `ErrCacheItemNotFound` anyway
return nil, ErrCacheItemNotFound
}
}
item := &cachedItem{}
if err = decodeGob(cacheHit.Data, item); err != nil {
return nil, err
}
return item.Val, nil
}
func (dc *databaseCache) Set(key string, value interface{}, expire time.Duration) error {
item := &cachedItem{Val: value}
data, err := encodeGob(item)
if err != nil {
return err
}
session := dc.SQLStore.NewSession()
var cacheHit CacheData
has, err := session.Where("cache_key = ?", key).Get(&cacheHit)
if err != nil {
return err
}
var expiresInSeconds int64
if expire != 0 {
expiresInSeconds = int64(expire) / int64(time.Second)
}
// insert or update depending on if item already exist
if has {
sql := `UPDATE cache_data SET data=?, created=?, expire=? WHERE cache_key='?'`
_, err = session.Exec(sql, data, getTime().Unix(), expiresInSeconds, key)
} else {
sql := `INSERT INTO cache_data (cache_key,data,created_at,expires) VALUES(?,?,?,?)`
_, err = session.Exec(sql, key, data, getTime().Unix(), expiresInSeconds)
}
return err
}
func (dc *databaseCache) Delete(key string) error {
sql := "DELETE FROM cache_data WHERE cache_key=?"
_, err := dc.SQLStore.NewSession().Exec(sql, key)
return err
}
type CacheData struct {
CacheKey string
Data []byte
Expires int64
CreatedAt int64
}
@@ -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)
}
@@ -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)
}
@@ -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
View File
@@ -0,0 +1,62 @@
package remotecache
import (
"time"
"github.com/grafana/grafana/pkg/setting"
redis "gopkg.in/redis.v2"
)
const redisCacheType = "redis"
type redisStorage struct {
c *redis.Client
}
func newRedisStorage(opts *setting.RemoteCacheOptions) *redisStorage {
opt := &redis.Options{
Network: "tcp",
Addr: opts.ConnStr,
}
return &redisStorage{c: redis.NewClient(opt)}
}
// Set sets value to given key in session.
func (s *redisStorage) Set(key string, val interface{}, expires time.Duration) error {
item := &cachedItem{Val: val}
value, err := encodeGob(item)
if err != nil {
return err
}
status := s.c.SetEx(key, expires, string(value))
return status.Err()
}
// Get gets value by given key in session.
func (s *redisStorage) Get(key string) (interface{}, error) {
v := s.c.Get(key)
item := &cachedItem{}
err := decodeGob([]byte(v.Val()), item)
if err == nil {
return item.Val, nil
}
if err.Error() == "EOF" {
return nil, ErrCacheItemNotFound
}
if err != nil {
return nil, err
}
return item.Val, nil
}
// Delete delete a key from session.
func (s *redisStorage) Delete(key string) error {
cmd := s.c.Del(key)
return cmd.Err()
}
@@ -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
View File
@@ -0,0 +1,133 @@
package remotecache
import (
"bytes"
"context"
"encoding/gob"
"errors"
"time"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/registry"
)
var (
// ErrCacheItemNotFound is returned if cache does not exist
ErrCacheItemNotFound = errors.New("cache item not found")
// ErrInvalidCacheType is returned if the type is invalid
ErrInvalidCacheType = errors.New("invalid remote cache name")
defaultMaxCacheExpiration = time.Hour * 24
)
func init() {
registry.RegisterService(&RemoteCache{})
}
// CacheStorage allows the caller to set, get and delete items in the cache.
// Cached items are stored as byte arrays and marshalled using "encoding/gob"
// so any struct added to the cache needs to be registred with `remotecache.Register`
// ex `remotecache.Register(CacheableStruct{})``
type CacheStorage interface {
// Get reads object from Cache
Get(key string) (interface{}, error)
// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
Set(key string, value interface{}, expire time.Duration) error
// Delete object from cache
Delete(key string) error
}
// RemoteCache allows Grafana to cache data outside its own process
type RemoteCache struct {
log log.Logger
client CacheStorage
SQLStore *sqlstore.SqlStore `inject:""`
Cfg *setting.Cfg `inject:""`
}
// Get reads object from Cache
func (ds *RemoteCache) Get(key string) (interface{}, error) {
return ds.client.Get(key)
}
// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
func (ds *RemoteCache) Set(key string, value interface{}, expire time.Duration) error {
if expire == 0 {
expire = defaultMaxCacheExpiration
}
return ds.client.Set(key, value, expire)
}
// Delete object from cache
func (ds *RemoteCache) Delete(key string) error {
return ds.client.Delete(key)
}
// Init initializes the service
func (ds *RemoteCache) Init() error {
ds.log = log.New("cache.remote")
var err error
ds.client, err = createClient(ds.Cfg.RemoteCacheOptions, ds.SQLStore)
return err
}
// Run start the backend processes for cache clients
func (ds *RemoteCache) Run(ctx context.Context) error {
//create new interface if more clients need GC jobs
backgroundjob, ok := ds.client.(registry.BackgroundService)
if ok {
return backgroundjob.Run(ctx)
}
<-ctx.Done()
return ctx.Err()
}
func createClient(opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) (CacheStorage, error) {
if opts.Name == redisCacheType {
return newRedisStorage(opts), nil
}
if opts.Name == memcachedCacheType {
return newMemcachedStorage(opts), nil
}
if opts.Name == databaseCacheType {
return newDatabaseCache(sqlstore), nil
}
return nil, ErrInvalidCacheType
}
// Register records a type, identified by a value for that type, under its
// internal type name. That name will identify the concrete type of a value
// sent or received as an interface variable. Only types that will be
// transferred as implementations of interface values need to be registered.
// Expecting to be used only during initialization, it panics if the mapping
// between types and names is not a bijection.
func Register(value interface{}) {
gob.Register(value)
}
type cachedItem struct {
Val interface{}
}
func encodeGob(item *cachedItem) ([]byte, error) {
buf := bytes.NewBuffer(nil)
err := gob.NewEncoder(buf).Encode(item)
return buf.Bytes(), err
}
func decodeGob(data []byte, out *cachedItem) error {
buf := bytes.NewBuffer(data)
return gob.NewDecoder(buf).Decode(&out)
}
+93
View File
@@ -0,0 +1,93 @@
package remotecache
import (
"testing"
"time"
"github.com/bmizerany/assert"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
type CacheableStruct struct {
String string
Int64 int64
}
func init() {
Register(CacheableStruct{})
}
func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) CacheStorage {
t.Helper()
dc := &RemoteCache{
SQLStore: sqlstore,
Cfg: &setting.Cfg{
RemoteCacheOptions: opts,
},
}
err := dc.Init()
if err != nil {
t.Fatalf("failed to init client for test. error: %v", err)
}
return dc
}
func TestCachedBasedOnConfig(t *testing.T) {
cfg := setting.NewCfg()
cfg.Load(&setting.CommandLineArgs{
HomePath: "../../../",
})
client := createTestClient(t, cfg.RemoteCacheOptions, sqlstore.InitTestDB(t))
runTestsForClient(t, client)
}
func TestInvalidCacheTypeReturnsError(t *testing.T) {
_, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil)
assert.Equal(t, err, ErrInvalidCacheType)
}
func runTestsForClient(t *testing.T, client CacheStorage) {
canPutGetAndDeleteCachedObjects(t, client)
canNotFetchExpiredItems(t, client)
}
func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) {
cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
err := client.Set("key1", cacheableStruct, 0)
assert.Equal(t, err, nil, "expected nil. got: ", err)
data, err := client.Get("key1")
s, ok := data.(CacheableStruct)
assert.Equal(t, ok, true)
assert.Equal(t, s.String, "hej")
assert.Equal(t, s.Int64, int64(2000))
err = client.Delete("key1")
assert.Equal(t, err, nil)
_, err = client.Get("key1")
assert.Equal(t, err, ErrCacheItemNotFound)
}
func canNotFetchExpiredItems(t *testing.T, client CacheStorage) {
cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
err := client.Set("key1", cacheableStruct, time.Second)
assert.Equal(t, err, nil)
//not sure how this can be avoided when testing redis/memcached :/
<-time.After(time.Second + time.Millisecond)
// should not be able to read that value since its expired
_, err = client.Get("key1")
assert.Equal(t, err, ErrCacheItemNotFound)
}
+17
View File
@@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler {
}
}
}
// AdminOrFeatureEnabled creates a middleware that allows access
// if the signed in user is either an Org Admin or if the
// feature flag is enabled.
// Intended for when feature flags open up access to APIs that
// are otherwise only available to admins.
func AdminOrFeatureEnabled(enabled bool) macaron.Handler {
return func(c *m.ReqContext) {
if c.OrgRole == m.ROLE_ADMIN {
return
}
if !enabled {
accessForbidden(c)
}
}
}
+7 -60
View File
@@ -11,6 +11,7 @@ import (
msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) {
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: unhashedToken,
@@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) {
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
}, nil
}
sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
sc.userAuthTokenService.TryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
userToken.UnhashedToken = "rotated"
return true, nil
}
@@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return nil, m.ErrUserTokenNotFound
}
@@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
}))
session.Init(&msession.Options{}, 0)
sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine
session.StartSessionGC = func() {}
@@ -595,7 +596,7 @@ type scenarioContext struct {
handlerFunc handlerFunc
defaultHandler macaron.Handler
url string
userAuthTokenService *fakeUserAuthTokenService
userAuthTokenService *auth.FakeUserAuthTokenService
req *http.Request
}
@@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() {
type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext)
type fakeUserAuthTokenService struct {
createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error)
tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error)
lookupTokenProvider func(unhashedToken string) (*m.UserToken, error)
revokeTokenProvider func(token *m.UserToken) error
activeAuthTokenCount func() (int64, error)
}
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
return &fakeUserAuthTokenService{
createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
},
tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) {
return false, nil
},
lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
},
revokeTokenProvider: func(token *m.UserToken) error {
return nil
},
activeAuthTokenCount: func() (int64, error) {
return 10, nil
},
}
}
func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
return s.createTokenProvider(userId, clientIP, userAgent)
}
func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
return s.lookupTokenProvider(unhashedToken)
}
func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) {
return s.tryRotateTokenProvider(token, clientIP, userAgent)
}
func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error {
return s.revokeTokenProvider(token)
}
func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
return s.activeAuthTokenCount()
}
+2 -2
View File
@@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
@@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
+3 -2
View File
@@ -3,6 +3,7 @@ package middleware
import (
"testing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/bus"
@@ -36,7 +37,7 @@ func TestMiddlewareQuota(t *testing.T) {
},
}
fakeAuthTokenService := newFakeUserAuthTokenService()
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
qs := &quota.QuotaService{
AuthTokenService: fakeAuthTokenService,
}
@@ -87,7 +88,7 @@ func TestMiddlewareQuota(t *testing.T) {
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
macaron "gopkg.in/macaron.v1"
@@ -62,7 +63,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine
sc.m.Use(OrgRedirect())
+19 -14
View File
@@ -7,9 +7,12 @@ import (
// Typed errors
var (
ErrTeamNotFound = errors.New("Team not found")
ErrTeamNameTaken = errors.New("Team name is taken")
ErrTeamMemberNotFound = errors.New("Team member not found")
ErrTeamNotFound = errors.New("Team not found")
ErrTeamNameTaken = errors.New("Team name is taken")
ErrTeamMemberNotFound = errors.New("Team member not found")
ErrLastTeamAdmin = errors.New("Not allowed to remove last admin")
ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team")
ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org")
)
// Team model
@@ -59,22 +62,24 @@ type GetTeamsByUserQuery struct {
}
type SearchTeamsQuery struct {
Query string
Name string
Limit int
Page int
OrgId int64
Query string
Name string
Limit int
Page int
OrgId int64
UserIdFilter int64
Result SearchTeamQueryResult
}
type TeamDTO struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
Permission PermissionType `json:"permission"`
}
type SearchTeamQueryResult struct {
+32 -20
View File
@@ -12,11 +12,12 @@ var (
// TeamMember model
type TeamMember struct {
Id int64
OrgId int64
TeamId int64
UserId int64
External bool
Id int64
OrgId int64
TeamId int64
UserId int64
External bool // Signals that the membership has been created by an external systems, such as LDAP
Permission PermissionType
Created time.Time
Updated time.Time
@@ -26,16 +27,26 @@ type TeamMember struct {
// COMMANDS
type AddTeamMemberCommand struct {
UserId int64 `json:"userId" binding:"Required"`
OrgId int64 `json:"-"`
TeamId int64 `json:"-"`
External bool `json:"-"`
UserId int64 `json:"userId" binding:"Required"`
OrgId int64 `json:"-"`
TeamId int64 `json:"-"`
External bool `json:"-"`
Permission PermissionType `json:"-"`
}
type UpdateTeamMemberCommand struct {
UserId int64 `json:"-"`
OrgId int64 `json:"-"`
TeamId int64 `json:"-"`
Permission PermissionType `json:"permission"`
ProtectLastAdmin bool `json:"-"`
}
type RemoveTeamMemberCommand struct {
OrgId int64 `json:"-"`
UserId int64
TeamId int64
OrgId int64 `json:"-"`
UserId int64
TeamId int64
ProtectLastAdmin bool `json:"-"`
}
// ----------------------
@@ -53,12 +64,13 @@ type GetTeamMembersQuery struct {
// Projections and DTOs
type TeamMemberDTO struct {
OrgId int64 `json:"orgId"`
TeamId int64 `json:"teamId"`
UserId int64 `json:"userId"`
External bool `json:"-"`
Email string `json:"email"`
Login string `json:"login"`
AvatarUrl string `json:"avatarUrl"`
Labels []string `json:"labels"`
OrgId int64 `json:"orgId"`
TeamId int64 `json:"teamId"`
UserId int64 `json:"userId"`
External bool `json:"-"`
Email string `json:"email"`
Login string `json:"login"`
AvatarUrl string `json:"avatarUrl"`
Labels []string `json:"labels"`
Permission PermissionType `json:"permission"`
}
+10 -1
View File
@@ -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)
+51
View File
@@ -221,6 +221,57 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error {
return nil
}
func (s *UserAuthTokenService) RevokeAllUserTokens(userId int64) error {
sql := `DELETE from user_auth_token WHERE user_id = ?`
res, err := s.SQLStore.NewSession().Exec(sql, userId)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
s.log.Debug("all user tokens for user revoked", "userId", userId, "count", affected)
return nil
}
func (s *UserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
var token userAuthToken
exists, err := s.SQLStore.NewSession().Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token)
if err != nil {
return nil, err
}
if !exists {
return nil, models.ErrUserTokenNotFound
}
var result models.UserToken
token.toUserToken(&result)
return &result, nil
}
func (s *UserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
var tokens []*userAuthToken
err := s.SQLStore.NewSession().Where("user_id = ? AND created_at > ? AND rotated_at > ?", userId, s.createdAfterParam(), s.rotatedAfterParam()).Find(&tokens)
if err != nil {
return nil, err
}
result := []*models.UserToken{}
for _, token := range tokens {
var userToken models.UserToken
token.toUserToken(&userToken)
result = append(result, &userToken)
}
return result, nil
}
func (s *UserAuthTokenService) createdAfterParam() int64 {
tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
return getTime().Add(-tokenMaxLifetime).Unix()
+41
View File
@@ -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
View File
@@ -0,0 +1,81 @@
package auth
import "github.com/grafana/grafana/pkg/models"
type FakeUserAuthTokenService struct {
CreateTokenProvider func(userId int64, clientIP, userAgent string) (*models.UserToken, error)
TryRotateTokenProvider func(token *models.UserToken, clientIP, userAgent string) (bool, error)
LookupTokenProvider func(unhashedToken string) (*models.UserToken, error)
RevokeTokenProvider func(token *models.UserToken) error
RevokeAllUserTokensProvider func(userId int64) error
ActiveAuthTokenCount func() (int64, error)
GetUserTokenProvider func(userId, userTokenId int64) (*models.UserToken, error)
GetUserTokensProvider func(userId int64) ([]*models.UserToken, error)
}
func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
return &FakeUserAuthTokenService{
CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
return &models.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
},
TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) {
return false, nil
},
LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) {
return &models.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
},
RevokeTokenProvider: func(token *models.UserToken) error {
return nil
},
RevokeAllUserTokensProvider: func(userId int64) error {
return nil
},
ActiveAuthTokenCount: func() (int64, error) {
return 10, nil
},
GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) {
return nil, nil
},
GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) {
return nil, nil
},
}
}
func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
return s.CreateTokenProvider(userId, clientIP, userAgent)
}
func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
return s.LookupTokenProvider(unhashedToken)
}
func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
return s.TryRotateTokenProvider(token, clientIP, userAgent)
}
func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error {
return s.RevokeTokenProvider(token)
}
func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error {
return s.RevokeAllUserTokensProvider(userId)
}
func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
return s.ActiveAuthTokenCount()
}
func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
return s.GetUserTokenProvider(userId, userTokenId)
}
func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
return s.GetUserTokensProvider(userId)
}
+55
View File
@@ -0,0 +1,55 @@
package dashboards
import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"time"
)
func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error {
rtEditor := models.ROLE_EDITOR
rtViewer := models.ROLE_VIEWER
items := []*models.DashboardAcl{
{
OrgId: orgId,
DashboardId: dashboardId,
UserId: userId,
Permission: models.PERMISSION_ADMIN,
Created: time.Now(),
Updated: time.Now(),
},
}
if setViewAndEditPermissions {
items = append(items,
&models.DashboardAcl{
OrgId: orgId,
DashboardId: dashboardId,
Role: &rtEditor,
Permission: models.PERMISSION_EDIT,
Created: time.Now(),
Updated: time.Now(),
},
&models.DashboardAcl{
OrgId: orgId,
DashboardId: dashboardId,
Role: &rtViewer,
Permission: models.PERMISSION_VIEW,
Created: time.Now(),
Updated: time.Now(),
},
)
}
aclCmd := &models.UpdateDashboardAclCommand{
DashboardId: dashboardId,
Items: items,
}
if err := bus.Dispatch(aclCmd); err != nil {
return err
}
return nil
}
@@ -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) {
@@ -54,4 +54,8 @@ func addTeamMigrations(mg *Migrator) {
mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
Name: "external", Type: DB_Bool, Nullable: true,
}))
mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
Name: "permission", Type: DB_SmallInt, Nullable: true,
}))
}
+103 -19
View File
@@ -18,17 +18,30 @@ func init() {
bus.AddHandler("sql", GetTeamsByUser)
bus.AddHandler("sql", AddTeamMember)
bus.AddHandler("sql", UpdateTeamMember)
bus.AddHandler("sql", RemoveTeamMember)
bus.AddHandler("sql", GetTeamMembers)
}
func getTeamSearchSqlBase() string {
return `SELECT
team.id as id,
team.org_id,
team.name as name,
team.email as email,
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count,
team_member.permission
FROM team as team
INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? `
}
func getTeamSelectSqlBase() string {
return `SELECT
team.id as id,
team.org_id,
team.name as name,
team.email as email,
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count
FROM team as team `
}
@@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
// DeleteTeam will delete a team, its member and any permissions connected to the team
func DeleteTeam(cmd *m.DeleteTeamCommand) error {
return inTransaction(func(sess *DBSession) error {
if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
return err
} else if !teamExists {
return m.ErrTeamNotFound
}
deletes := []string{
@@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) {
if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
return false, err
} else if len(res) != 1 {
return false, nil
return false, m.ErrTeamNotFound
}
return true, nil
@@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(getTeamSelectSqlBase())
if query.UserIdFilter > 0 {
sql.WriteString(getTeamSearchSqlBase())
params = append(params, query.UserIdFilter)
} else {
sql.WriteString(getTeamSelectSqlBase())
}
sql.WriteString(` WHERE team.org_id = ?`)
params = append(params, query.OrgId)
@@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
return m.ErrTeamMemberAlreadyAdded
}
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
return err
} else if !teamExists {
return m.ErrTeamNotFound
}
entity := m.TeamMember{
OrgId: cmd.OrgId,
TeamId: cmd.TeamId,
UserId: cmd.UserId,
External: cmd.External,
Created: time.Now(),
Updated: time.Now(),
OrgId: cmd.OrgId,
TeamId: cmd.TeamId,
UserId: cmd.UserId,
External: cmd.External,
Created: time.Now(),
Updated: time.Now(),
Permission: cmd.Permission,
}
_, err := sess.Insert(&entity)
@@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
})
}
func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) {
rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?`
var member m.TeamMember
exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member)
if err != nil {
return member, err
}
if !exists {
return member, m.ErrTeamMemberNotFound
}
return member, nil
}
// UpdateTeamMember updates a team member
func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error {
return inTransaction(func(sess *DBSession) error {
member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
if err != nil {
return err
}
if cmd.ProtectLastAdmin {
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
if err != nil {
return err
}
}
if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store
cmd.Permission = 0
}
member.Permission = cmd.Permission
_, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member)
return err
})
}
// RemoveTeamMember removes a member from a team
func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
return inTransaction(func(sess *DBSession) error {
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
return err
} else if !teamExists {
return m.ErrTeamNotFound
}
if cmd.ProtectLastAdmin {
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
if err != nil {
return err
}
}
var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
@@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
})
}
func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) {
rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?"
userIds := []*int64{}
err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds)
if err != nil {
return false, err
}
isAdmin := false
for _, adminId := range userIds {
if userId == *adminId {
isAdmin = true
break
}
}
if isAdmin && len(userIds) == 1 {
return true, m.ErrLastTeamAdmin
}
return false, err
}
// GetTeamMembers return a list of members for the specified team
func GetTeamMembers(query *m.GetTeamMembersQuery) error {
query.Result = make([]*m.TeamMemberDTO, 0)
@@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
if query.External {
sess.Where("team_member.external=?", dialect.BooleanStr(true))
}
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external")
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission")
sess.Asc("user.login", "user.email")
err := sess.Find(&query.Result)
+93
View File
@@ -75,6 +75,72 @@ func TestTeamCommandsAndQueries(t *testing.T) {
So(q2.Result[0].External, ShouldEqual, true)
})
Convey("Should be able to update users in a team", func() {
userId := userIds[0]
team := group1.Result
addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userId}
err = AddTeamMember(&addMemberCmd)
So(err, ShouldBeNil)
qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qBeforeUpdate)
So(err, ShouldBeNil)
So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
UserId: userId,
OrgId: testOrgId,
TeamId: team.Id,
Permission: m.PERMISSION_ADMIN,
})
So(err, ShouldBeNil)
qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qAfterUpdate)
So(err, ShouldBeNil)
So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
})
Convey("Should default to member permission level when updating a user with invalid permission level", func() {
userID := userIds[0]
team := group1.Result
addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID}
err = AddTeamMember(&addMemberCmd)
So(err, ShouldBeNil)
qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qBeforeUpdate)
So(err, ShouldBeNil)
So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
invalidPermissionLevel := m.PERMISSION_EDIT
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
UserId: userID,
OrgId: testOrgId,
TeamId: team.Id,
Permission: invalidPermissionLevel,
})
So(err, ShouldBeNil)
qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qAfterUpdate)
So(err, ShouldBeNil)
So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0)
})
Convey("Shouldn't be able to update a user not in the team.", func() {
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
UserId: 1,
OrgId: testOrgId,
TeamId: group1.Result.Id,
Permission: m.PERMISSION_ADMIN,
})
So(err, ShouldEqual, m.ErrTeamMemberNotFound)
})
Convey("Should be able to search for teams", func() {
query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
err = SearchTeams(query)
@@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) {
So(len(q2.Result), ShouldEqual, 0)
})
Convey("When ProtectLastAdmin is set to true", func() {
err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN})
So(err, ShouldBeNil)
Convey("A user should not be able to remove the last admin", func() {
err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
So(err, ShouldEqual, m.ErrLastTeamAdmin)
})
Convey("A user should be able to remove an admin if there are other admins", func() {
AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
So(err, ShouldEqual, nil)
})
Convey("A user should not be able to remove the admin permission for the last admin", func() {
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
So(err, ShouldEqual, m.ErrLastTeamAdmin)
})
Convey("A user should be able to remove the admin permission if there are other admins", func() {
AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
So(err, ShouldEqual, nil)
})
})
Convey("Should be able to remove a group with users and permissions", func() {
groupId := group2.Result.Id
err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})
+34
View File
@@ -0,0 +1,34 @@
package teamguardian
import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
)
func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error {
if user.OrgRole == m.ROLE_ADMIN {
return nil
}
if user.OrgId != orgId {
return m.ErrNotAllowedToUpdateTeamInDifferentOrg
}
cmd := m.GetTeamMembersQuery{
OrgId: orgId,
TeamId: teamId,
UserId: user.UserId,
}
if err := bus.Dispatch(&cmd); err != nil {
return err
}
for _, member := range cmd.Result {
if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN {
return nil
}
}
return m.ErrNotAllowedToUpdateTeam
}
+87
View File
@@ -0,0 +1,87 @@
package teamguardian
import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
"testing"
)
func TestUpdateTeam(t *testing.T) {
Convey("Updating a team", t, func() {
bus.ClearBusHandlers()
admin := m.SignedInUser{
UserId: 1,
OrgId: 1,
OrgRole: m.ROLE_ADMIN,
}
editor := m.SignedInUser{
UserId: 2,
OrgId: 1,
OrgRole: m.ROLE_EDITOR,
}
testTeam := m.Team{
Id: 1,
OrgId: 1,
}
Convey("Given an editor and a team he isn't a member of", func() {
Convey("Should not be able to update the team", func() {
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
cmd.Result = []*m.TeamMemberDTO{}
return nil
})
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam)
})
})
Convey("Given an editor and a team he is an admin in", func() {
Convey("Should be able to update the team", func() {
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
cmd.Result = []*m.TeamMemberDTO{{
OrgId: testTeam.OrgId,
TeamId: testTeam.Id,
UserId: editor.UserId,
Permission: m.PERMISSION_ADMIN,
}}
return nil
})
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
So(err, ShouldBeNil)
})
})
Convey("Given an editor and a team in another org", func() {
testTeamOtherOrg := m.Team{
Id: 1,
OrgId: 2,
}
Convey("Shouldn't be able to update the team", func() {
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
cmd.Result = []*m.TeamMemberDTO{{
OrgId: testTeamOtherOrg.OrgId,
TeamId: testTeamOtherOrg.Id,
UserId: editor.UserId,
Permission: m.PERMISSION_ADMIN,
}}
return nil
})
err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor)
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg)
})
})
Convey("Given an org admin and a team", func() {
Convey("Should be able to update the team", func() {
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin)
So(err, ShouldBeNil)
})
})
})
}
+20 -3
View File
@@ -239,8 +239,13 @@ type Cfg struct {
LoginMaxLifetimeDays int
TokenRotationIntervalMinutes int
// User
EditorsCanOwn bool
// Dataproxy
SendUserHeader bool
// DistributedCache
RemoteCacheOptions *RemoteCacheOptions
EditorsCanAdmin bool
}
type CommandLineArgs struct {
@@ -601,6 +606,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
dataproxy := iniFile.Section("dataproxy")
DataProxyLogging = dataproxy.Key("logging").MustBool(false)
DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false)
// read security settings
security := iniFile.Section("security")
@@ -663,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
ExternalUserMngInfo = users.Key("external_manage_info").String()
ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false)
cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)
// auth
auth := iniFile.Section("auth")
@@ -781,9 +787,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
enterprise := iniFile.Section("enterprise")
cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
cacheServer := iniFile.Section("remote_cache")
cfg.RemoteCacheOptions = &RemoteCacheOptions{
Name: cacheServer.Key("type").MustString("database"),
ConnStr: cacheServer.Key("connstr").MustString(""),
}
return nil
}
type RemoteCacheOptions struct {
Name string
ConnStr string
}
func (cfg *Cfg) readSessionConfig() {
sec := cfg.Raw.Section("session")
SessionOptions = session.Options{}

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