Merge branch 'master' into bar-gauge-refactoring

This commit is contained in:
Torkel Ödegaard
2019-03-19 13:40:02 +01:00
111 changed files with 3412 additions and 661 deletions
+17
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
@@ -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
+4
View File
@@ -8,6 +8,10 @@
* **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)
+14
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.
+14
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.
@@ -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"
}
```
+29 -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 />
@@ -399,6 +411,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
<hr />
## [dataproxy]
### logging
This enables data proxy logging, default is false.
### timeout
How long the data proxy should wait before timing out default is 30 (seconds)
### send_user_header
If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
<hr />
## [analytics]
### reporting_enabled
@@ -11,17 +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,
};
@@ -3,26 +3,21 @@ 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';
}
@@ -30,44 +25,30 @@ export class BarGauge extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
maxValue: 100,
minValue: 0,
value: 100,
unit: 'none',
displayMode: 'basic',
value: {
text: '100',
numeric: 100,
},
displayMode: 'lcd',
orientation: VizOrientation.Horizontal,
thresholds: [],
valueMappings: [],
};
render() {
const { maxValue, minValue, unit, decimals, displayMode } = this.props;
const numericValue = this.getNumericValue();
const valuePercent = Math.min(numericValue / (maxValue - minValue), 1);
const formatFunc = getValueFormat(unit);
const valueFormatted = formatFunc(numericValue, decimals);
switch (displayMode) {
switch (this.props.displayMode) {
case 'lcd':
return this.renderRetroBars(valueFormatted, valuePercent);
return this.renderRetroBars();
case 'basic':
case 'gradient':
default:
return this.renderBasicAndGradientBars(valueFormatted, valuePercent);
return this.renderBasicAndGradientBars();
}
}
getNumericValue(): number {
if (Number.isFinite(this.props.value as number)) {
return this.props.value as number;
}
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);
@@ -111,9 +92,8 @@ export class BarGauge extends PureComponent<Props> {
}
getBarGradient(maxSize: number): string {
const { minValue, maxValue, thresholds } = this.props;
const { minValue, maxValue, thresholds, value } = this.props;
const cssDirection = this.isVertical ? '0deg' : '90deg';
const currentValue = this.getNumericValue();
let gradient = '';
let lastpos = 0;
@@ -127,7 +107,7 @@ export class BarGauge extends PureComponent<Props> {
if (gradient === '') {
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
} else if (currentValue < threshold.value) {
} else if (value.numeric < threshold.value) {
break;
} else {
lastpos = pos;
@@ -135,18 +115,18 @@ export class BarGauge extends PureComponent<Props> {
}
}
console.log(gradient);
return gradient + ')';
}
renderBasicAndGradientBars(valueFormatted: string, valuePercent: number): ReactNode {
const { height, width, displayMode } = this.props;
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 spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
const valueStyles = this.getValueStyles(valueFormatted, colors.value, spaceForText);
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
const isBasic = displayMode === 'basic';
const containerStyles: CSSProperties = {
@@ -199,7 +179,7 @@ export class BarGauge extends PureComponent<Props> {
return (
<div style={containerStyles}>
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
{value.text}
</div>
<div style={barStyles} />
</div>
@@ -214,7 +194,7 @@ export class BarGauge extends PureComponent<Props> {
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)) {
if (value === null || (positionValue !== null && positionValue > value.numeric)) {
return {
background: tinycolor(color)
.setAlpha(0.15)
@@ -244,8 +224,8 @@ export class BarGauge extends PureComponent<Props> {
};
}
renderRetroBars(valueFormatted: string, valuePercent: number): ReactNode {
const { height, width, maxValue, minValue } = this.props;
renderRetroBars(): ReactNode {
const { height, width, maxValue, minValue, value } = this.props;
const valueRange = maxValue - minValue;
const maxSize = this.size * BAR_SIZE_RATIO;
@@ -254,7 +234,7 @@ export class BarGauge extends PureComponent<Props> {
const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
const colors = this.getValueColors();
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
const valueStyles = this.getValueStyles(valueFormatted, colors.value, spaceForText);
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
const containerStyles: CSSProperties = {
width: `${width}px`,
@@ -305,7 +285,7 @@ export class BarGauge extends PureComponent<Props> {
<div style={containerStyles}>
{cells}
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
{value.text}
</div>
</div>
);
@@ -27,10 +27,13 @@ exports[`Render BarGauge with basic options should render 1`] = `
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.3)",
"borderRight": "1px solid #7EB26D",
"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",
}
}
@@ -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]) {
@@ -161,7 +161,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' },
@@ -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';
+1
View File
@@ -5,3 +5,4 @@ export * from './plugin';
export * from './datasource';
export * from './theme';
export * from './threshold';
export * from './input';
+2 -2
View File
@@ -30,10 +30,10 @@ export interface PanelEditorProps<T = any> {
* Called before a panel is initalized
*/
export type PanelTypeChangedHook<TOptions = any> = (
options: TOptions,
options: Partial<TOptions>,
prevPluginId?: string,
prevOptions?: any
) => TOptions;
) => Partial<TOptions>;
export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>;
+1
View File
@@ -91,6 +91,7 @@ export interface PluginMeta {
includes: PluginInclude[];
// Datasource-specific
builtIn?: boolean;
metrics?: boolean;
tables?: boolean;
logs?: boolean;
@@ -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);
}
+2
View File
@@ -5,5 +5,7 @@ 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';
+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)
})
}
+7
View File
@@ -133,6 +133,9 @@ func (hs *HTTPServer) registerRoutes() {
userRoute.Get("/preferences", Wrap(GetUserPreferences))
userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences))
userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens))
userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken))
})
// users (admin permission required)
@@ -375,6 +378,10 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
adminRoute.Get("/stats", AdminGetStats)
adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts))
adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser))
adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens))
adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
}, reqGrafanaAdmin)
// rendering
+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() {
+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"`
}
+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
}
+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)
}
+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())
+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)
}
+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)
}
@@ -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) {
+18
View File
@@ -241,6 +241,12 @@ type Cfg struct {
// User
EditorsCanOwn bool
// Dataproxy
SendUserHeader bool
// DistributedCache
RemoteCacheOptions *RemoteCacheOptions
}
type CommandLineArgs struct {
@@ -601,6 +607,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
dataproxy := iniFile.Section("dataproxy")
DataProxyLogging = dataproxy.Key("logging").MustBool(false)
DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false)
// read security settings
security := iniFile.Section("security")
@@ -781,9 +788,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
enterprise := iniFile.Section("enterprise")
cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
cacheServer := iniFile.Section("remote_cache")
cfg.RemoteCacheOptions = &RemoteCacheOptions{
Name: cacheServer.Key("type").MustString("database"),
ConnStr: cacheServer.Key("connstr").MustString(""),
}
return nil
}
type RemoteCacheOptions struct {
Name string
ConnStr string
}
func (cfg *Cfg) readSessionConfig() {
sec := cfg.Raw.Section("session")
SessionOptions = session.Options{}
+4 -1
View File
@@ -3,6 +3,7 @@ package mssql
import (
"database/sql"
"fmt"
"github.com/grafana/grafana/pkg/setting"
"strconv"
_ "github.com/denisenkom/go-mssqldb"
@@ -24,7 +25,9 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
if err != nil {
return nil, err
}
logger.Debug("getEngine", "connection", cnnstr)
if setting.Env == setting.DEV {
logger.Debug("getEngine", "connection", cnnstr)
}
config := tsdb.SqlQueryEndpointConfiguration{
DriverName: "mssql",
+4 -1
View File
@@ -3,6 +3,7 @@ package mysql
import (
"database/sql"
"fmt"
"github.com/grafana/grafana/pkg/setting"
"reflect"
"strconv"
"strings"
@@ -44,7 +45,9 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
cnnstr += "&tls=" + tlsConfigString
}
logger.Debug("getEngine", "connection", cnnstr)
if setting.Env == setting.DEV {
logger.Debug("getEngine", "connection", cnnstr)
}
config := tsdb.SqlQueryEndpointConfiguration{
DriverName: "mysql",
+4 -1
View File
@@ -2,6 +2,7 @@ package postgres
import (
"database/sql"
"github.com/grafana/grafana/pkg/setting"
"net/url"
"strconv"
@@ -19,7 +20,9 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp
logger := log.New("tsdb.postgres")
cnnstr := generateConnectionString(datasource)
logger.Debug("getEngine", "connection", cnnstr)
if setting.Env == setting.DEV {
logger.Debug("getEngine", "connection", cnnstr)
}
config := tsdb.SqlQueryEndpointConfiguration{
DriverName: "postgres",
+69 -1
View File
@@ -2,6 +2,7 @@ package testdata
import (
"encoding/json"
"math"
"math/rand"
"strconv"
"strings"
@@ -100,6 +101,15 @@ func init() {
},
})
registerScenario(&Scenario{
Id: "random_walk_table",
Name: "Random Walk Table",
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
return getRandomWalkTable(query, context)
},
})
registerScenario(&Scenario{
Id: "slow_query",
Name: "Slow Query",
@@ -251,7 +261,7 @@ func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResu
series := newSeriesForQuery(query)
points := make(tsdb.TimeSeriesPoints, 0)
walker := rand.Float64() * 100
walker := query.Model.Get("startValue").MustFloat64(rand.Float64() * 100)
for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
@@ -267,6 +277,64 @@ func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResu
return queryRes
}
func getRandomWalkTable(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
to := tsdbQuery.TimeRange.GetToAsMsEpoch()
table := tsdb.Table{
Columns: []tsdb.TableColumn{
{Text: "Time"},
{Text: "Value"},
{Text: "Min"},
{Text: "Max"},
{Text: "Info"},
},
Rows: []tsdb.RowValues{},
}
withNil := query.Model.Get("withNil").MustBool(false)
walker := query.Model.Get("startValue").MustFloat64(rand.Float64() * 100)
spread := 2.5
var info strings.Builder
for i := int64(0); i < query.MaxDataPoints && timeWalkerMs < to; i++ {
delta := rand.Float64() - 0.5
walker += delta
info.Reset()
if delta > 0 {
info.WriteString("up")
} else {
info.WriteString("down")
}
if math.Abs(delta) > .4 {
info.WriteString(" fast")
}
row := tsdb.RowValues{
float64(timeWalkerMs),
walker,
walker - ((rand.Float64() * spread) + 0.01), // Min
walker + ((rand.Float64() * spread) + 0.01), // Max
info.String(),
}
// Add some random null values
if withNil && rand.Float64() > 0.8 {
for i := 1; i < 4; i++ {
if rand.Float64() > .2 {
row[i] = nil
}
}
}
table.Rows = append(table.Rows, row)
timeWalkerMs += query.IntervalMs
}
queryRes := tsdb.NewQueryResult()
queryRes.Tables = append(queryRes.Tables, &table)
return queryRes
}
func registerScenario(scenario *Scenario) {
ScenarioRegistry[scenario.Id] = scenario
}
+94
View File
@@ -0,0 +1,94 @@
package testdata
import (
"testing"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb"
. "github.com/smartystreets/goconvey/convey"
)
func TestTestdataScenarios(t *testing.T) {
Convey("random walk ", t, func() {
scenario, _ := ScenarioRegistry["random_walk"]
Convey("Should start at the requested value", func() {
req := &tsdb.TsdbQuery{
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
Queries: []*tsdb.Query{
{RefId: "A", IntervalMs: 100, MaxDataPoints: 100, Model: simplejson.New()},
},
}
query := req.Queries[0]
query.Model.Set("startValue", 1.234)
result := scenario.Handler(req.Queries[0], req)
points := result.Series[0].Points
So(result.Series, ShouldNotBeNil)
So(points[0][0].Float64, ShouldEqual, 1.234)
})
})
Convey("random walk table", t, func() {
scenario, _ := ScenarioRegistry["random_walk_table"]
Convey("Should return a table that looks like value/min/max", func() {
req := &tsdb.TsdbQuery{
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
Queries: []*tsdb.Query{
{RefId: "A", IntervalMs: 100, MaxDataPoints: 100, Model: simplejson.New()},
},
}
result := scenario.Handler(req.Queries[0], req)
table := result.Tables[0]
So(len(table.Rows), ShouldBeGreaterThan, 50)
for _, row := range table.Rows {
value := row[1]
min := row[2]
max := row[3]
So(min, ShouldBeLessThan, value)
So(max, ShouldBeGreaterThan, value)
}
})
Convey("Should return a table with some nil values", func() {
req := &tsdb.TsdbQuery{
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()),
Queries: []*tsdb.Query{
{RefId: "A", IntervalMs: 100, MaxDataPoints: 100, Model: simplejson.New()},
},
}
query := req.Queries[0]
query.Model.Set("withNil", true)
result := scenario.Handler(req.Queries[0], req)
table := result.Tables[0]
nil1 := false
nil2 := false
nil3 := false
So(len(table.Rows), ShouldBeGreaterThan, 50)
for _, row := range table.Rows {
if row[1] == nil {
nil1 = true
}
if row[2] == nil {
nil2 = true
}
if row[3] == nil {
nil3 = true
}
}
So(nil1, ShouldBeTrue)
So(nil2, ShouldBeTrue)
So(nil3, ShouldBeTrue)
})
})
}
+44 -34
View File
@@ -17,12 +17,13 @@ import 'vendor/angular-other/angular-strap';
import $ from 'jquery';
import angular from 'angular';
import config from 'app/core/config';
// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move
import _ from 'lodash';
import moment from 'moment';
import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar';
// add move to lodash for backward compatabiltiy
_.move = (array, fromIndex, toIndex) => {
_.move = (array: [], fromIndex: number, toIndex: number) => {
array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
return array;
};
@@ -36,7 +37,7 @@ import 'app/features/all';
// import symlinked extensions
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
extensionsIndex.keys().forEach(key => {
extensionsIndex.keys().forEach((key: any) => {
extensionsIndex(key);
});
@@ -52,7 +53,7 @@ export class GrafanaApp {
this.ngModuleDependencies = [];
}
useModule(module) {
useModule(module: angular.IModule) {
if (this.preBootModules) {
this.preBootModules.push(module);
} else {
@@ -67,40 +68,49 @@ export class GrafanaApp {
moment.locale(config.bootData.user.locale);
app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => {
// pre assing bindings before constructor calls
$compileProvider.preAssignBindingsEnabled(true);
app.config(
(
$locationProvider: angular.ILocationProvider,
$controllerProvider: angular.IControllerProvider,
$compileProvider: angular.ICompileProvider,
$filterProvider: angular.IFilterProvider,
$httpProvider: angular.IHttpProvider,
$provide: angular.auto.IProvideService
) => {
// pre assing bindings before constructor calls
$compileProvider.preAssignBindingsEnabled(true);
if (config.buildInfo.env !== 'development') {
$compileProvider.debugInfoEnabled(false);
}
if (config.buildInfo.env !== 'development') {
$compileProvider.debugInfoEnabled(false);
}
$httpProvider.useApplyAsync(true);
$httpProvider.useApplyAsync(true);
this.registerFunctions.controller = $controllerProvider.register;
this.registerFunctions.directive = $compileProvider.directive;
this.registerFunctions.factory = $provide.factory;
this.registerFunctions.service = $provide.service;
this.registerFunctions.filter = $filterProvider.register;
this.registerFunctions.controller = $controllerProvider.register;
this.registerFunctions.directive = $compileProvider.directive;
this.registerFunctions.factory = $provide.factory;
this.registerFunctions.service = $provide.service;
this.registerFunctions.filter = $filterProvider.register;
$provide.decorator('$http', [
'$delegate',
'$templateCache',
($delegate, $templateCache) => {
const get = $delegate.get;
$delegate.get = (url, config) => {
if (url.match(/\.html$/)) {
// some template's already exist in the cache
if (!$templateCache.get(url)) {
url += '?v=' + new Date().getTime();
$provide.decorator('$http', [
'$delegate',
'$templateCache',
($delegate: any, $templateCache: any) => {
const get = $delegate.get;
$delegate.get = (url: string, config: any) => {
if (url.match(/\.html$/)) {
// some template's already exist in the cache
if (!$templateCache.get(url)) {
url += '?v=' + new Date().getTime();
}
}
}
return get(url, config);
};
return $delegate;
},
]);
});
return get(url, config);
};
return $delegate;
},
]);
}
);
this.ngModuleDependencies = [
'grafana.core',
@@ -116,7 +126,7 @@ export class GrafanaApp {
];
// makes it possible to add dynamic stuff
_.each(angularModules, m => {
_.each(angularModules, (m: angular.IModule) => {
this.useModule(m);
});
@@ -129,7 +139,7 @@ export class GrafanaApp {
// bootstrap the app
angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
_.each(this.preBootModules, module => {
_.each(this.preBootModules, (module: angular.IModule) => {
_.extend(module, this.registerFunctions);
});
-1
View File
@@ -1 +0,0 @@
export { Input } from './Input';
@@ -1,7 +1,30 @@
// @ts-ignore
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
import { BackendSrv } from 'app/core/services/backend_srv';
import { NavModelSrv } from 'app/core/nav_model_srv';
import { ContextSrv } from 'app/core/services/context_srv';
export interface Section {
id: number;
uid: string;
title: string;
expanded: false;
items: any[];
url: string;
icon: string;
score: number;
checked: boolean;
hideHeader: boolean;
toggle: Function;
}
export interface FoldersAndDashboardUids {
folderUids: string[];
dashboardUids: string[];
}
class Query {
query: string;
@@ -14,7 +37,7 @@ class Query {
}
export class ManageDashboardsCtrl {
sections: any[];
sections: Section[];
query: Query;
navModel: any;
@@ -45,7 +68,12 @@ export class ManageDashboardsCtrl {
hasEditPermissionInFolders: boolean;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
constructor(
private backendSrv: BackendSrv,
navModelSrv: NavModelSrv,
private searchSrv: SearchSrv,
private contextSrv: ContextSrv
) {
this.isEditor = this.contextSrv.isEditor;
this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders;
@@ -73,7 +101,7 @@ export class ManageDashboardsCtrl {
refreshList() {
return this.searchSrv
.search(this.query)
.then(result => {
.then((result: Section[]) => {
return this.initDashboardList(result);
})
.then(() => {
@@ -81,7 +109,7 @@ export class ManageDashboardsCtrl {
return;
}
return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
return this.backendSrv.getFolderByUid(this.folderUid).then((folder: any) => {
this.canSave = folder.canSave;
if (!this.canSave) {
this.hasEditPermissionInFolders = false;
@@ -90,7 +118,7 @@ export class ManageDashboardsCtrl {
});
}
initDashboardList(result: any) {
initDashboardList(result: Section[]) {
this.canMove = false;
this.canDelete = false;
this.selectAllChecked = false;
@@ -128,25 +156,25 @@ export class ManageDashboardsCtrl {
this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
}
getFoldersAndDashboardsToDelete() {
const selectedDashboards = {
folders: [],
dashboards: [],
getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids {
const selectedDashboards: FoldersAndDashboardUids = {
folderUids: [],
dashboardUids: [],
};
for (const section of this.sections) {
if (section.checked && section.id !== 0) {
selectedDashboards.folders.push(section.uid);
selectedDashboards.folderUids.push(section.uid);
} else {
const selected = _.filter(section.items, { checked: true });
selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
selectedDashboards.dashboardUids.push(..._.map(selected, 'uid'));
}
}
return selectedDashboards;
}
getFolderIds(sections) {
getFolderIds(sections: Section[]) {
const ids = [];
for (const s of sections) {
if (s.checked) {
@@ -158,8 +186,8 @@ export class ManageDashboardsCtrl {
delete() {
const data = this.getFoldersAndDashboardsToDelete();
const folderCount = data.folders.length;
const dashCount = data.dashboards.length;
const folderCount = data.folderUids.length;
const dashCount = data.dashboardUids.length;
let text = 'Do you want to delete the ';
let text2;
@@ -179,12 +207,12 @@ export class ManageDashboardsCtrl {
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
this.deleteFoldersAndDashboards(data.folders, data.dashboards);
this.deleteFoldersAndDashboards(data.folderUids, data.dashboardUids);
},
});
}
private deleteFoldersAndDashboards(folderUids, dashboardUids) {
private deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
this.refreshList();
});
@@ -219,13 +247,13 @@ export class ManageDashboardsCtrl {
}
initTagFilter() {
return this.searchSrv.getDashboardTags().then(results => {
return this.searchSrv.getDashboardTags().then((results: any) => {
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
});
}
filterByTag(tag) {
filterByTag(tag: any) {
if (_.indexOf(this.query.tag, tag) === -1) {
this.query.tag.push(tag);
}
@@ -243,7 +271,7 @@ export class ManageDashboardsCtrl {
return res;
}
removeTag(tag, evt) {
removeTag(tag: any, evt: Event) {
this.query.tag = _.without(this.query.tag, tag);
this.refreshList();
if (evt) {
@@ -269,7 +297,7 @@ export class ManageDashboardsCtrl {
section.checked = this.selectAllChecked;
}
section.items = _.map(section.items, item => {
section.items = _.map(section.items, (item: any) => {
item.checked = this.selectAllChecked;
return item;
});
+1 -1
View File
@@ -13,7 +13,7 @@ export interface BuildInfo {
export class Settings {
datasources: any;
panels: PanelPlugin[];
panels: { [key: string]: PanelPlugin };
appSubUrl: string;
windowTitlePrefix: string;
buildInfo: BuildInfo;
+89 -55
View File
@@ -1,12 +1,39 @@
import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards';
import { SearchSrv } from 'app/core/services/search_srv';
// @ts-ignore
import q from 'q';
import {
ManageDashboardsCtrl,
Section,
FoldersAndDashboardUids,
} from 'app/core/components/manage_dashboards/manage_dashboards';
import { SearchSrv } from 'app/core/services/search_srv';
import { BackendSrv } from '../services/backend_srv';
import { NavModelSrv } from '../nav_model_srv';
import { ContextSrv } from '../services/context_srv';
const mockSection = (overides?: object): Section => {
const defaultSection: Section = {
id: 0,
items: [],
checked: false,
expanded: false,
hideHeader: false,
icon: '',
score: 0,
title: 'Some Section',
toggle: jest.fn(),
uid: 'someuid',
url: '/some/url/',
};
return { ...defaultSection, ...overides };
};
describe('ManageDashboards', () => {
let ctrl;
let ctrl: ManageDashboardsCtrl;
describe('when browsing dashboards', () => {
beforeEach(() => {
const tags: any[] = [];
const response = [
{
id: 410,
@@ -18,11 +45,11 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
tags: [],
tags,
isStarred: false,
},
],
tags: [],
tags,
isStarred: false,
},
{
@@ -37,11 +64,11 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
tags: [],
tags,
isStarred: false,
},
],
tags: [],
tags,
isStarred: false,
},
];
@@ -61,6 +88,7 @@ describe('ManageDashboards', () => {
describe('when browsing dashboards for a folder', () => {
beforeEach(() => {
const tags: any[] = [];
const response = [
{
id: 410,
@@ -72,11 +100,11 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
tags: [],
tags,
isStarred: false,
},
],
tags: [],
tags,
isStarred: false,
},
];
@@ -92,6 +120,7 @@ describe('ManageDashboards', () => {
describe('when searching dashboards', () => {
beforeEach(() => {
const tags: any[] = [];
const response = [
{
checked: false,
@@ -103,7 +132,7 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
tags: [],
tags,
isStarred: false,
folderId: 410,
folderUid: 'uid',
@@ -115,7 +144,7 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
tags: [],
tags,
folderId: 499,
isStarred: false,
},
@@ -245,7 +274,7 @@ describe('ManageDashboards', () => {
});
describe('when selecting dashboards', () => {
let ctrl;
let ctrl: ManageDashboardsCtrl;
beforeEach(() => {
ctrl = createCtrlWithStubs([]);
@@ -254,16 +283,16 @@ describe('ManageDashboards', () => {
describe('and no dashboards are selected', () => {
beforeEach(() => {
ctrl.sections = [
{
mockSection({
id: 1,
items: [{ id: 2, checked: false }],
checked: false,
},
{
}),
mockSection({
id: 0,
items: [{ id: 3, checked: false }],
checked: false,
},
}),
];
ctrl.selectionChanged();
});
@@ -302,16 +331,16 @@ describe('ManageDashboards', () => {
describe('and all folders and dashboards are selected', () => {
beforeEach(() => {
ctrl.sections = [
{
mockSection({
id: 1,
items: [{ id: 2, checked: true }],
checked: true,
},
{
}),
mockSection({
id: 0,
items: [{ id: 3, checked: true }],
checked: true,
},
}),
];
ctrl.selectionChanged();
});
@@ -350,18 +379,18 @@ describe('ManageDashboards', () => {
describe('and one dashboard in root is selected', () => {
beforeEach(() => {
ctrl.sections = [
{
mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: false }],
checked: false,
},
{
}),
mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true }],
checked: false,
},
}),
];
ctrl.selectionChanged();
});
@@ -378,18 +407,18 @@ describe('ManageDashboards', () => {
describe('and one child dashboard is selected', () => {
beforeEach(() => {
ctrl.sections = [
{
mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: true }],
checked: false,
},
{
}),
mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: false }],
checked: false,
},
}),
];
ctrl.selectionChanged();
@@ -407,18 +436,18 @@ describe('ManageDashboards', () => {
describe('and one child dashboard and one dashboard is selected', () => {
beforeEach(() => {
ctrl.sections = [
{
mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: true }],
checked: false,
},
{
}),
mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true }],
checked: false,
},
}),
];
ctrl.selectionChanged();
@@ -436,24 +465,24 @@ describe('ManageDashboards', () => {
describe('and one child dashboard and one folder is selected', () => {
beforeEach(() => {
ctrl.sections = [
{
mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: false }],
checked: true,
},
{
}),
mockSection({
id: 3,
title: 'folder',
items: [{ id: 4, checked: true }],
checked: false,
},
{
}),
mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: false }],
checked: false,
},
}),
];
ctrl.selectionChanged();
@@ -470,55 +499,55 @@ describe('ManageDashboards', () => {
});
describe('when deleting dashboards', () => {
let toBeDeleted: any;
let toBeDeleted: FoldersAndDashboardUids;
beforeEach(() => {
ctrl = createCtrlWithStubs([]);
ctrl.sections = [
{
mockSection({
id: 1,
uid: 'folder',
title: 'folder',
items: [{ id: 2, checked: true, uid: 'folder-dash' }],
checked: true,
},
{
}),
mockSection({
id: 3,
title: 'folder-2',
items: [{ id: 3, checked: true, uid: 'folder-2-dash' }],
checked: false,
uid: 'folder-2',
},
{
}),
mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true, uid: 'root-dash' }],
checked: true,
},
}),
];
toBeDeleted = ctrl.getFoldersAndDashboardsToDelete();
});
it('should return 1 folder', () => {
expect(toBeDeleted.folders.length).toEqual(1);
expect(toBeDeleted.folderUids.length).toEqual(1);
});
it('should return 2 dashboards', () => {
expect(toBeDeleted.dashboards.length).toEqual(2);
expect(toBeDeleted.dashboardUids.length).toEqual(2);
});
it('should filter out children if parent is checked', () => {
expect(toBeDeleted.folders[0]).toEqual('folder');
expect(toBeDeleted.folderUids[0]).toEqual('folder');
});
it('should not filter out children if parent not is checked', () => {
expect(toBeDeleted.dashboards[0]).toEqual('folder-2-dash');
expect(toBeDeleted.dashboardUids[0]).toEqual('folder-2-dash');
});
it('should not filter out children if parent is checked and root', () => {
expect(toBeDeleted.dashboards[1]).toEqual('root-dash');
expect(toBeDeleted.dashboardUids[1]).toEqual('root-dash');
});
});
@@ -527,19 +556,19 @@ describe('ManageDashboards', () => {
ctrl = createCtrlWithStubs([]);
ctrl.sections = [
{
mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: true, uid: 'dash' }],
checked: false,
uid: 'folder',
},
{
}),
mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true, uid: 'dash-2' }],
checked: false,
},
}),
];
});
@@ -562,5 +591,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
},
};
return new ManageDashboardsCtrl({}, { getNav: () => {} }, searchSrvStub as SearchSrv, { isEditor: true });
return new ManageDashboardsCtrl(
{} as BackendSrv,
{ getNav: () => {} } as NavModelSrv,
searchSrvStub as SearchSrv,
{ isEditor: true } as ContextSrv
);
}
+5 -8
View File
@@ -9,6 +9,7 @@ import store from 'app/core/store';
import { parse as parseDate } from 'app/core/utils/datemath';
import { colors } from '@grafana/ui';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { getNextRefIdChar } from './query';
// Types
import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
@@ -225,12 +226,8 @@ export function generateKey(index = 0): string {
return `Q-${Date.now()}-${Math.random()}-${index}`;
}
export function generateRefId(index = 0): string {
return `${index + 1}`;
}
export function generateEmptyQuery(index = 0): { refId: string; key: string } {
return { refId: generateRefId(index), key: generateKey(index) };
export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery {
return { refId: getNextRefIdChar(queries), key: generateKey(index) };
}
/**
@@ -238,9 +235,9 @@ export function generateEmptyQuery(index = 0): { refId: string; key: string } {
*/
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
if (queries && typeof queries === 'object' && queries.length > 0) {
return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(queries, i) }));
}
return [{ ...generateEmptyQuery() }];
return [{ ...generateEmptyQuery(queries) }];
}
/**
+30
View File
@@ -0,0 +1,30 @@
import { DataQuery } from '@grafana/ui';
import { getNextRefIdChar } from './query';
const dataQueries: DataQuery[] = [
{
refId: 'A',
},
{
refId: 'B',
},
{
refId: 'C',
},
{
refId: 'D',
},
{
refId: 'E',
},
];
describe('Get next refId char', () => {
it('should return next char', () => {
expect(getNextRefIdChar(dataQueries)).toEqual('F');
});
it('should get first char', () => {
expect(getNextRefIdChar([])).toEqual('A');
});
});
+12
View File
@@ -0,0 +1,12 @@
import _ from 'lodash';
import { DataQuery } from '@grafana/ui/';
export const getNextRefIdChar = (queries: DataQuery[]): string => {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
return _.every(queries, other => {
return other.refId !== refId;
});
});
};
-16
View File
@@ -1,16 +0,0 @@
import { ValidationRule, ValidationEvents } from 'app/types';
import { EventsWithValidation } from 'app/core/components/Form/Input';
export const validate = (value: string, validationRules: ValidationRule[]) => {
const errors = validationRules.reduce((acc, currRule) => {
if (!currRule.rule(value)) {
return acc.concat(currRule.errorMessage);
}
return acc;
}, []);
return errors.length > 0 ? errors : null;
};
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => {
return validationEvents && validationEvents[event];
};
@@ -4,13 +4,16 @@ jest.mock('app/core/store', () => {
};
});
// @ts-ignore
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardExporter } from './DashboardExporter';
import { DashboardModel } from '../../state/DashboardModel';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PanelPlugin } from 'app/types';
describe('given dashboard with repeated panels', () => {
let dash, exported;
let dash: any, exported: any;
beforeEach(done => {
dash = {
@@ -89,25 +92,25 @@ describe('given dashboard with repeated panels', () => {
config.buildInfo.version = '3.0.2';
//Stubs test function calls
const datasourceSrvStub = { get: jest.fn(arg => getStub(arg)) };
const datasourceSrvStub = ({ get: jest.fn(arg => getStub(arg)) } as any) as DatasourceSrv;
config.panels['graph'] = {
id: 'graph',
name: 'Graph',
info: { version: '1.1.0' },
};
} as PanelPlugin;
config.panels['table'] = {
id: 'table',
name: 'Table',
info: { version: '1.1.1' },
};
} as PanelPlugin;
config.panels['heatmap'] = {
id: 'heatmap',
name: 'Heatmap',
info: { version: '1.1.2' },
};
} as PanelPlugin;
dash = new DashboardModel(dash, {});
const exporter = new DashboardExporter(datasourceSrvStub);
@@ -213,7 +216,7 @@ describe('given dashboard with repeated panels', () => {
});
// Stub responses
const stubs = [];
const stubs: { [key: string]: {} } = {};
stubs['gfdb'] = {
name: 'gfdb',
meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' },
@@ -249,6 +252,6 @@ stubs['-- Grafana --'] = {
},
};
function getStub(arg) {
function getStub(arg: string) {
return Promise.resolve(stubs[arg || 'gfdb']);
}
@@ -1,9 +1,42 @@
import config from 'app/core/config';
// @ts-ignore
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardModel } from '../../state/DashboardModel';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { PanelModel } from 'app/features/dashboard/state';
import { PanelPlugin } from 'app/types/plugins';
interface Input {
name: string;
type: string;
label: string;
value: any;
description: string;
}
interface Requires {
[key: string]: {
type: string;
id: string;
name: string;
version: string;
};
}
interface DataSources {
[key: string]: {
name: string;
label: string;
description: string;
type: string;
pluginId: string;
pluginName: string;
};
}
export class DashboardExporter {
constructor(private datasourceSrv) {}
constructor(private datasourceSrv: DatasourceSrv) {}
makeExportable(dashboard: DashboardModel) {
// clean up repeated rows and panels,
@@ -18,19 +51,19 @@ export class DashboardExporter {
// undo repeat cleanup
dashboard.processRepeats();
const inputs = [];
const requires = {};
const datasources = {};
const promises = [];
const variableLookup: any = {};
const inputs: Input[] = [];
const requires: Requires = {};
const datasources: DataSources = {};
const promises: Array<Promise<void>> = [];
const variableLookup: { [key: string]: any } = {};
for (const variable of saveModel.templating.list) {
variableLookup[variable.name] = variable;
}
const templateizeDatasourceUsage = obj => {
let datasource = obj.datasource;
let datasourceVariable = null;
const templateizeDatasourceUsage = (obj: any) => {
let datasource: string = obj.datasource;
let datasourceVariable: any = null;
// ignore data source properties that contain a variable
if (datasource && datasource.indexOf('$') === 0) {
@@ -74,7 +107,7 @@ export class DashboardExporter {
);
};
const processPanel = panel => {
const processPanel = (panel: PanelModel) => {
if (panel.datasource !== undefined) {
templateizeDatasourceUsage(panel);
}
@@ -87,7 +120,7 @@ export class DashboardExporter {
}
}
const panelDef = config.panels[panel.type];
const panelDef: PanelPlugin = config.panels[panel.type];
if (panelDef) {
requires['panel' + panelDef.id] = {
type: 'panel',
@@ -135,7 +168,7 @@ export class DashboardExporter {
return Promise.all(promises)
.then(() => {
_.each(datasources, (value, key) => {
_.each(datasources, (value: any) => {
inputs.push(value);
});
@@ -160,7 +193,7 @@ export class DashboardExporter {
}
// make inputs and requires a top thing
const newObj = {};
const newObj: { [key: string]: {} } = {};
newObj['__inputs'] = inputs;
newObj['__requires'] = _.sortBy(requires, ['id']);
@@ -5,17 +5,12 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
// Components
import { Switch } from '@grafana/ui';
import { Input } from 'app/core/components/Form';
import { EventsWithValidation } from 'app/core/components/Form/Input';
import { InputStatus } from 'app/core/components/Form/Input';
import { DataSourceSelectItem, EventsWithValidation, Input, InputStatus, Switch, ValidationEvents } from '@grafana/ui';
import { DataSourceOption } from './DataSourceOption';
import { FormLabel } from '@grafana/ui';
// Types
import { PanelModel } from '../state/PanelModel';
import { DataSourceSelectItem } from '@grafana/ui/src/types';
import { ValidationEvents } from 'app/types';
import { PanelModel } from '../state';
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
@@ -3,9 +3,10 @@ import { PanelModel } from './PanelModel';
describe('PanelModel', () => {
describe('when creating new panel model', () => {
let model;
let modelJson;
beforeEach(() => {
model = new PanelModel({
modelJson = {
type: 'table',
showColumns: true,
targets: [{ refId: 'A' }, { noRefId: true }],
@@ -23,7 +24,8 @@ describe('PanelModel', () => {
},
],
},
});
};
model = new PanelModel(modelJson);
});
it('should apply defaults', () => {
@@ -38,6 +40,15 @@ describe('PanelModel', () => {
expect(model.targets[1].refId).toBe('B');
});
it("shouldn't break panel with non-array targets", () => {
modelJson.targets = {
0: { refId: 'A' },
foo: { bar: 'baz' },
};
model = new PanelModel(modelJson);
expect(model.targets[0].refId).toBe('A');
});
it('getSaveModel should remove defaults', () => {
const saveModel = model.getSaveModel();
expect(saveModel.gridPos).toBe(undefined);
@@ -1,8 +1,11 @@
// Libraries
import _ from 'lodash';
// Types
// Utils
import { Emitter } from 'app/core/utils/emitter';
import { getNextRefIdChar } from 'app/core/utils/query';
// Types
import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui';
import { TableData } from '@grafana/ui/src';
@@ -125,10 +128,10 @@ export class PanelModel {
}
ensureQueryIds() {
if (this.targets) {
if (this.targets && _.isArray(this.targets)) {
for (const query of this.targets) {
if (!query.refId) {
query.refId = this.getNextQueryLetter();
query.refId = getNextRefIdChar(this.targets);
}
}
}
@@ -266,20 +269,10 @@ export class PanelModel {
addQuery(query?: Partial<DataQuery>) {
query = query || { refId: 'A' };
query.refId = this.getNextQueryLetter();
query.refId = getNextRefIdChar(this.targets);
this.targets.push(query as DataQuery);
}
getNextQueryLetter(): string {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
return _.every(this.targets, other => {
return other.refId !== refId;
});
});
}
changeQuery(query: DataQuery, index: number) {
// ensure refId is maintained
query.refId = this.targets[index].refId;
@@ -11,7 +11,9 @@ function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) {
return (
<div className={className}>
<div className="logs-stats-row__label">
<div className="logs-stats-row__value">{value}</div>
<div className="logs-stats-row__value" title={value}>
{value}
</div>
<div className="logs-stats-row__count">{count}</div>
<div className="logs-stats-row__percent">{percent}</div>
</div>
+6 -2
View File
@@ -40,11 +40,15 @@ export default class Table extends PureComponent<TableProps> {
const tableModel = data || EMPTY_TABLE;
const columnNames = tableModel.columns.map(({ text }) => text);
const columns = tableModel.columns.map(({ filterable, text }) => ({
Header: text,
Header: () => <span title={text}>{text}</span>,
accessor: text,
className: VALUE_REGEX.test(text) ? 'text-right' : '',
show: text !== 'Time',
Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
Cell: row => (
<span className={filterable ? 'link' : ''} title={text + ': ' + row.value}>
{row.value}
</span>
),
}));
const noDataText = data ? 'The queries returned no data for a table.' : '';
+11 -9
View File
@@ -60,7 +60,6 @@ import {
splitCloseAction,
splitOpenAction,
addQueryRowAction,
AddQueryRowPayload,
toggleGraphAction,
toggleLogsAction,
toggleTableAction,
@@ -87,9 +86,12 @@ const updateExploreUIState = (exploreId, uiStateFragment: Partial<ExploreUIState
/**
* Adds a query row after the row with the given index.
*/
export function addQueryRow(exploreId: ExploreId, index: number): ActionOf<AddQueryRowPayload> {
const query = generateEmptyQuery(index + 1);
return addQueryRowAction({ exploreId, index, query });
export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
return (dispatch, getState) => {
const query = generateEmptyQuery(getState().explore[exploreId].queries, index);
dispatch(addQueryRowAction({ exploreId, index, query }));
};
}
/**
@@ -126,10 +128,10 @@ export function changeQuery(
index: number,
override: boolean
): ThunkResult<void> {
return dispatch => {
return (dispatch, getState) => {
// Null query means reset
if (query === null) {
query = { ...generateEmptyQuery(index) };
query = { ...generateEmptyQuery(getState().explore[exploreId].queries) };
}
dispatch(changeQueryAction({ exploreId, query, index, override }));
@@ -287,7 +289,7 @@ export function importQueries(
const nextQueries = importedQueries.map((q, i) => ({
...q,
...generateEmptyQuery(i),
...generateEmptyQuery(queries),
}));
dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
@@ -629,9 +631,9 @@ export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkRes
* Use this action for clicks on query examples. Triggers a query run.
*/
export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> {
return dispatch => {
return (dispatch, getState) => {
// Inject react keys into query objects
const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery(getState().explore[exploreId].queries) }));
dispatch(setQueriesAction({ exploreId, queries }));
dispatch(runQueries(exploreId));
};
@@ -127,7 +127,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
const { query, index } = action.payload;
// Override path: queries are completely reset
const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(index) };
const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(state.queries) };
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
@@ -267,7 +267,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
// Modify all queries
nextQueries = queries.map((query, i) => ({
...modifier({ ...query }, modification),
...generateEmptyQuery(i),
...generateEmptyQuery(state.queries),
}));
// Discard all ongoing transactions
nextQueryTransactions = [];
@@ -276,7 +276,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
nextQueries = queries.map((query, i) => {
// Synchronize all queries with local query cache to ensure consistency
// TODO still needed?
return i === index ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(i) } : query;
return i === index
? { ...modifier({ ...query }, modification), ...generateEmptyQuery(state.queries) }
: query;
});
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
@@ -25,6 +25,7 @@ import * as heatmapPanel from 'app/plugins/panel/heatmap/module';
import * as tablePanel from 'app/plugins/panel/table/module';
import * as table2Panel from 'app/plugins/panel/table2/module';
import * as singlestatPanel from 'app/plugins/panel/singlestat/module';
import * as singlestatPanel2 from 'app/plugins/panel/singlestat2/module';
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
import * as gaugePanel from 'app/plugins/panel/gauge/module';
import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
@@ -57,6 +58,7 @@ const builtInPlugins = {
'app/plugins/panel/table/module': tablePanel,
'app/plugins/panel/table2/module': table2Panel,
'app/plugins/panel/singlestat/module': singlestatPanel,
'app/plugins/panel/singlestat2/module': singlestatPanel2,
'app/plugins/panel/gettingstarted/module': gettingStartedPanel,
'app/plugins/panel/gauge/module': gaugePanel,
'app/plugins/panel/bargauge/module': barGaugePanel,
@@ -2,56 +2,47 @@
import React, { PureComponent } from 'react';
// Services & Utils
import { processSingleStatPanelData } from '@grafana/ui';
import { DisplayValue, PanelProps, BarGauge } from '@grafana/ui';
import { config } from 'app/core/config';
// Components
import { BarGauge, VizRepeater } from '@grafana/ui';
// Types
import { BarGaugeOptions } from './types';
import { PanelProps, SingleStatValueInfo } from '@grafana/ui/src/types';
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
interface Props extends PanelProps<BarGaugeOptions> {}
export class BarGaugePanel extends PureComponent<Props> {
renderBarGauge(value: SingleStatValueInfo, width, height) {
const { replaceVariables, options } = this.props;
const { valueOptions } = options;
const prefix = replaceVariables(valueOptions.prefix);
const suffix = replaceVariables(valueOptions.suffix);
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
const { options } = this.props;
return (
<BarGauge
value={value.value as number | null}
value={value}
width={width}
height={height}
prefix={prefix}
suffix={suffix}
orientation={options.orientation}
unit={valueOptions.unit}
decimals={valueOptions.decimals}
thresholds={options.thresholds}
valueMappings={options.valueMappings}
theme={config.theme}
displayMode={options.displayMode}
/>
);
}
};
getProcessedValues = (): DisplayValue[] => {
return getSingleStatValues(this.props);
};
render() {
const { panelData, options, width, height } = this.props;
const values = processSingleStatPanelData({
panelData: panelData,
stat: options.valueOptions.stat,
});
const { height, width, options, panelData } = this.props;
const { orientation } = options;
return (
<VizRepeater height={height} width={width} values={values} orientation={options.orientation}>
{({ vizHeight, vizWidth, value }) => this.renderBarGauge(value, vizWidth, vizHeight)}
</VizRepeater>
<ProcessedValuesRepeater
getProcessedValues={this.getProcessedValues}
renderValue={this.renderValue}
width={width}
height={height}
source={panelData}
orientation={orientation}
/>
);
}
}
@@ -2,13 +2,13 @@
import React, { PureComponent } from 'react';
// Components
import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor';
import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGroup, FormField } from '@grafana/ui';
// Types
import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
import { BarGaugeOptions, orientationOptions, displayModes } from './types';
import { SingleStatValueOptions } from '../gauge/types';
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
import { SingleStatValueOptions } from '../singlestat2/types';
export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) =>
+2 -10
View File
@@ -3,18 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui';
import { BarGaugePanel } from './BarGaugePanel';
import { BarGaugePanelEditor } from './BarGaugePanelEditor';
import { BarGaugeOptions, defaults } from './types';
import { singleStatBaseOptionsCheck } from '../singlestat2/module';
export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel);
reactPanel.setEditor(BarGaugePanelEditor);
reactPanel.setDefaults(defaults);
reactPanel.setPanelTypeChangedHook((options: BarGaugeOptions, prevPluginId?: string, prevOptions?: any) => {
if (prevOptions && prevOptions.valueOptions) {
options.valueOptions = prevOptions.valueOptions;
options.thresholds = prevOptions.thresholds;
options.maxValue = prevOptions.maxValue;
options.minValue = prevOptions.minValue;
}
return options;
});
reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);
+9 -13
View File
@@ -1,31 +1,27 @@
import { Threshold, SelectOptionItem, ValueMapping, VizOrientation } from '@grafana/ui';
import { SingleStatValueOptions } from '../gauge/types';
import { VizOrientation, SelectOptionItem } from '@grafana/ui';
import { SingleStatBaseOptions } from '../singlestat2/types';
export interface BarGaugeOptions {
export interface BarGaugeOptions extends SingleStatBaseOptions {
minValue: number;
maxValue: number;
orientation: VizOrientation;
valueOptions: SingleStatValueOptions;
valueMappings: ValueMapping[];
thresholds: Threshold[];
displayMode: 'basic' | 'lcd' | 'gradient';
}
export const orientationOptions: SelectOptionItem[] = [
{ value: VizOrientation.Horizontal, label: 'Horizontal' },
{ value: VizOrientation.Vertical, label: 'Vertical' },
];
export const displayModes: SelectOptionItem[] = [
{ value: 'gradient', label: 'Gradient' },
{ value: 'lcd', label: 'Retro LCD' },
{ value: 'basic', label: 'Basic' },
];
export const orientationOptions: SelectOptionItem[] = [
{ value: VizOrientation.Horizontal, label: 'Horizontal' },
{ value: VizOrientation.Vertical, label: 'Vertical' },
];
export const defaults: BarGaugeOptions = {
minValue: 0,
maxValue: 100,
displayMode: 'basic',
displayMode: 'lcd',
orientation: VizOrientation.Horizontal,
valueOptions: {
unit: 'none',
+23 -29
View File
@@ -2,37 +2,27 @@
import React, { PureComponent } from 'react';
// Services & Utils
import { processSingleStatPanelData } from '@grafana/ui';
import { config } from 'app/core/config';
// Components
import { Gauge, VizRepeater } from '@grafana/ui';
import { Gauge } from '@grafana/ui';
// Types
import { GaugeOptions } from './types';
import { PanelProps, VizOrientation, SingleStatValueInfo } from '@grafana/ui/src/types';
import { DisplayValue, PanelProps } from '@grafana/ui';
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
interface Props extends PanelProps<GaugeOptions> {}
export class GaugePanel extends PureComponent<Props> {
renderGauge(value: SingleStatValueInfo, width, height) {
const { replaceVariables, options } = this.props;
const { valueOptions } = options;
const prefix = replaceVariables(valueOptions.prefix);
const suffix = replaceVariables(valueOptions.suffix);
export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
const { options } = this.props;
return (
<Gauge
value={value.value as number | null}
value={value}
width={width}
height={height}
prefix={prefix}
suffix={suffix}
unit={valueOptions.unit}
decimals={valueOptions.decimals}
thresholds={options.thresholds}
valueMappings={options.valueMappings}
showThresholdLabels={options.showThresholdLabels}
showThresholdMarkers={options.showThresholdMarkers}
minValue={options.minValue}
@@ -40,20 +30,24 @@ export class GaugePanel extends PureComponent<Props> {
theme={config.theme}
/>
);
}
};
getProcessedValues = (): DisplayValue[] => {
return getSingleStatValues(this.props);
};
render() {
const { panelData, options, height, width } = this.props;
const values = processSingleStatPanelData({
panelData: panelData,
stat: options.valueOptions.stat,
});
const { height, width, options, panelData } = this.props;
const { orientation } = options;
return (
<VizRepeater height={height} width={width} values={values} orientation={VizOrientation.Auto}>
{({ vizHeight, vizWidth, value }) => this.renderGauge(value, vizWidth, vizHeight)}
</VizRepeater>
<ProcessedValuesRepeater
getProcessedValues={this.getProcessedValues}
renderValue={this.renderValue}
width={width}
height={height}
source={panelData}
orientation={orientation}
/>
);
}
}
@@ -9,9 +9,10 @@ import {
ValueMapping,
} from '@grafana/ui';
import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor';
import { GaugeOptionsBox } from './GaugeOptionsBox';
import { GaugeOptions, SingleStatValueOptions } from './types';
import { GaugeOptions } from './types';
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
import { SingleStatValueOptions } from '../singlestat2/types';
export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) =>
+2 -10
View File
@@ -3,18 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui';
import { GaugePanelEditor } from './GaugePanelEditor';
import { GaugePanel } from './GaugePanel';
import { GaugeOptions, defaults } from './types';
import { singleStatBaseOptionsCheck } from '../singlestat2/module';
export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel);
reactPanel.setEditor(GaugePanelEditor);
reactPanel.setDefaults(defaults);
reactPanel.setPanelTypeChangedHook((options: GaugeOptions, prevPluginId?: string, prevOptions?: any) => {
if (prevOptions && prevOptions.valueOptions) {
options.valueOptions = prevOptions.valueOptions;
options.thresholds = prevOptions.thresholds;
options.maxValue = prevOptions.maxValue;
options.minValue = prevOptions.minValue;
}
return options;
});
reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);
+4 -13
View File
@@ -1,21 +1,11 @@
import { Threshold, ValueMapping } from '@grafana/ui';
import { SingleStatBaseOptions } from '../singlestat2/types';
import { VizOrientation } from '@grafana/ui';
export interface GaugeOptions {
valueMappings: ValueMapping[];
export interface GaugeOptions extends SingleStatBaseOptions {
maxValue: number;
minValue: number;
showThresholdLabels: boolean;
showThresholdMarkers: boolean;
thresholds: Threshold[];
valueOptions: SingleStatValueOptions;
}
export interface SingleStatValueOptions {
unit: string;
suffix: string;
stat: string;
prefix: string;
decimals?: number | null;
}
export const defaults: GaugeOptions = {
@@ -32,4 +22,5 @@ export const defaults: GaugeOptions = {
},
valueMappings: [],
thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
orientation: VizOrientation.Auto,
};
@@ -0,0 +1,48 @@
import React, { PureComponent } from 'react';
import { VizOrientation } from '@grafana/ui';
import { VizRepeater } from '@grafana/ui';
export interface Props<T> {
width: number;
height: number;
orientation: VizOrientation;
source: any; // If this changes, the values will be processed
processFlag?: boolean; // change to force processing
getProcessedValues: () => T[];
renderValue: (value: T, width: number, height: number) => JSX.Element;
}
interface State<T> {
values: T[];
}
/**
* This is essentially a cache of processed values. This checks for changes
* to the source and then saves the processed values in the State
*/
export class ProcessedValuesRepeater<T> extends PureComponent<Props<T>, State<T>> {
constructor(props: Props<T>) {
super(props);
this.state = {
values: props.getProcessedValues(),
};
}
componentDidUpdate(prevProps: Props<T>) {
const { processFlag, source } = this.props;
if (processFlag !== prevProps.processFlag || source !== prevProps.source) {
this.setState({ values: this.props.getProcessedValues() });
}
}
render() {
const { orientation, height, width, renderValue } = this.props;
const { values } = this.state;
return (
<VizRepeater height={height} width={width} values={values} orientation={orientation}>
{({ vizHeight, vizWidth, value }) => renderValue(value, vizWidth, vizHeight)}
</VizRepeater>
);
}
}
@@ -0,0 +1,9 @@
# Singlestat Panel - Native Plugin
The Singlestat Panel is **included** with Grafana.
The Singlestat Panel allows you to show the one main summary stat of a SINGLE series. It reduces the series into a single number (by looking at the max, min, average, or sum of values in the series). Singlestat also provides thresholds to color the stat or the Panel background. It can also translate the single number into a text value, and show a sparkline summary of the series.
Read more about it here:
[http://docs.grafana.org/reference/singlestat/](http://docs.grafana.org/reference/singlestat/)
@@ -0,0 +1,48 @@
// Libraries
import React, { PureComponent } from 'react';
import {
PanelEditorProps,
ThresholdsEditor,
Threshold,
PanelOptionsGrid,
ValueMappingsEditor,
ValueMapping,
} from '@grafana/ui';
import { SingleStatOptions, SingleStatValueOptions } from './types';
import { SingleStatValueEditor } from './SingleStatValueEditor';
export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) =>
this.props.onOptionsChange({
...this.props.options,
thresholds,
});
onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
this.props.onOptionsChange({
...this.props.options,
valueMappings,
});
onValueOptionsChanged = (valueOptions: SingleStatValueOptions) =>
this.props.onOptionsChange({
...this.props.options,
valueOptions,
});
render() {
const { options } = this.props;
return (
<>
<PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} />
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
</>
);
}
}
@@ -0,0 +1,66 @@
// Libraries
import React, { PureComponent, CSSProperties } from 'react';
// Types
import { SingleStatOptions, SingleStatBaseOptions } from './types';
import { processSingleStatPanelData, DisplayValue, PanelProps } from '@grafana/ui';
import { config } from 'app/core/config';
import { getDisplayProcessor } from '@grafana/ui';
import { ProcessedValuesRepeater } from './ProcessedValuesRepeater';
export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): DisplayValue[] => {
const { panelData, replaceVariables, options } = props;
const { valueOptions, valueMappings } = options;
const processor = getDisplayProcessor({
unit: valueOptions.unit,
decimals: valueOptions.decimals,
mappings: valueMappings,
thresholds: options.thresholds,
prefix: replaceVariables(valueOptions.prefix),
suffix: replaceVariables(valueOptions.suffix),
theme: config.theme,
});
return processSingleStatPanelData({
panelData: panelData,
stat: valueOptions.stat,
}).map(stat => processor(stat.value));
};
export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
const style: CSSProperties = {};
style.margin = '0 auto';
style.fontSize = '250%';
style.textAlign = 'center';
if (value.color) {
style.color = value.color;
}
return (
<div style={{ width, height }}>
<div style={style}>{value.text}</div>
</div>
);
};
getProcessedValues = (): DisplayValue[] => {
return getSingleStatValues(this.props);
};
render() {
const { height, width, options, panelData } = this.props;
const { orientation } = options;
return (
<ProcessedValuesRepeater
getProcessedValues={this.getProcessedValues}
renderValue={this.renderValue}
width={width}
height={height}
source={panelData}
orientation={orientation}
/>
);
}
}
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.26;fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill:url(#SVGID_3_);}
.st3{fill:url(#SVGID_4_);}
.st4{fill:url(#SVGID_5_);}
.st5{fill:none;stroke:url(#SVGID_6_);stroke-miterlimit:10;}
</style>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="50" y1="65.6698" x2="50" y2="93.5681">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st0" d="M97.6,83.8H2.4c-1.3,0-2.4-1.1-2.4-2.4v-1.8l17-1l19.2-4.3l16.3-1.6l16.5,0l15.8-4.7l15.1-3v16.3
C100,82.8,98.9,83.8,97.6,83.8z"/>
<g>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.098" y1="76.0776" x2="19.098" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st1" d="M19.6,64.3V38.9l-5.2,3.9l-3.5-6l9.4-6.9h6.8v34.4H19.6z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="42.412" y1="76.0776" x2="42.412" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st2" d="M53.1,39.4c0,1.1-0.1,2.2-0.4,3.2c-0.3,1-0.7,1.9-1.2,2.8c-0.5,0.9-1,1.7-1.7,2.5c-0.6,0.8-1.2,1.6-1.9,2.3
l-6.4,7.4h11.1v6.7H32.3v-6.9l10.5-12c0.8-1,1.5-2,2-3c0.5-1,0.7-2,0.7-2.9c0-1-0.2-1.9-0.7-2.6c-0.5-0.7-1.2-1.1-2.2-1.1
c-0.9,0-1.7,0.4-2.3,1.1c-0.6,0.8-1,1.9-1.1,3.3l-7.3-0.7c0.4-3.5,1.6-6.1,3.6-7.9c2-1.7,4.5-2.6,7.4-2.6c1.6,0,3,0.2,4.3,0.7
c1.3,0.5,2.3,1.2,3.2,2c0.9,0.9,1.6,1.9,2.1,3.2C52.8,36.4,53.1,37.8,53.1,39.4z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="60.3739" y1="76.0776" x2="60.3739" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st3" d="M64.5,60.4c0,1.2-0.4,2.3-1.2,3.1c-0.8,0.8-1.8,1.3-3,1.3c-1.2,0-2.2-0.4-3-1.3c-0.8-0.8-1.1-1.9-1.1-3.1
c0-1.2,0.4-2.2,1.1-3.1c0.8-0.9,1.8-1.3,3-1.3c1.2,0,2.2,0.4,3,1.3C64.1,58.1,64.5,59.2,64.5,60.4z"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="77.5234" y1="76.0776" x2="77.5234" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st4" d="M85.5,57.4v6.9h-6.9v-6.9H66v-6.6l10.1-20.9h9.4V51H89v6.4H85.5z M78.8,37.5L78.8,37.5l-6,13.5h6V37.5z"/>
</g>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-2.852199e-02" y1="72.3985" x2="100.0976" y2="72.3985">
<stop offset="0" style="stop-color:#F28F3F"/>
<stop offset="1" style="stop-color:#F28F3F"/>
</linearGradient>
<polyline class="st5" points="0,79.7 17,78.7 36.2,74.4 52.5,72.8 69,72.9 84.9,68.1 100,65.1 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,29 @@
import { ReactPanelPlugin } from '@grafana/ui';
import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types';
import { SingleStatPanel } from './SingleStatPanel';
import cloneDeep from 'lodash/cloneDeep';
import { SingleStatEditor } from './SingleStatEditor';
export const reactPanel = new ReactPanelPlugin<SingleStatOptions>(SingleStatPanel);
const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
export const singleStatBaseOptionsCheck = (
options: Partial<SingleStatBaseOptions>,
prevPluginId?: string,
prevOptions?: any
) => {
if (prevOptions) {
optionsToKeep.forEach(v => {
if (prevOptions.hasOwnProperty(v)) {
options[v] = cloneDeep(prevOptions.display);
}
});
}
return options;
};
reactPanel.setEditor(SingleStatEditor);
reactPanel.setDefaults(defaults);
reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);
@@ -0,0 +1,20 @@
{
"type": "panel",
"name": "Singlestat (react)",
"id": "singlestat2",
"state": "alpha",
"dataFormats": ["time_series", "table"],
"info": {
"description": "Singlestat Panel for Grafana",
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icn-singlestat-panel.svg",
"large": "img/icn-singlestat-panel.svg"
}
}
}

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