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

@@ -262,6 +261,19 @@ Set to `true` to log the sql calls and execution times. For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared) Defaults to private. +
+ +## [remote_cache] + +### type + +Either `redis`, `memcached` or `database` default is `database` + +### connstr + +The remote cache connection string. Leave empty when using `database` since it will use the primary database. +Redis example config: `addr=127.0.0.1:6379,pool_size=100,db=grafana` +Memcache example: `127.0.0.1:11211`
@@ -399,6 +411,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
+## [dataproxy] + +### logging + +This enables data proxy logging, default is false. + +### timeout + +How long the data proxy should wait before timing out default is 30 (seconds) + +### send_user_header + +If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false. + +
+ ## [analytics] ### reporting_enabled diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index eca2e8079e2..69cdbf5bff1 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -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, }; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index e4f0f37b4a1..99d2d061a11 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -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 { static defaultProps: Partial = { 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 { } 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 { 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 { } } - 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 { return (
- {valueFormatted} + {value.text}
@@ -214,7 +194,7 @@ export class BarGauge extends PureComponent { 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 { }; } - 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 { 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 {
{cells}
- {valueFormatted} + {value.text}
); diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index 077389be418..25a2e79b647 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -27,10 +27,13 @@ exports[`Render BarGauge with basic options should render 1`] = `
({ @@ -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'); - }); -}); diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 3d1567d4937..f812cc971bc 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -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 { static defaultProps: Partial = { 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 { 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 { 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 { 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 { }, }; - const plotSeries = { data: [[0, value]] }; + const plotSeries = { data: [[0, value.numeric]] }; try { $.plot(this.canvasElement, [plotSeries], options); diff --git a/public/app/core/components/Form/Input.test.tsx b/packages/grafana-ui/src/components/Input/Input.test.tsx similarity index 83% rename from public/app/core/components/Form/Input.test.tsx rename to packages/grafana-ui/src/components/Input/Input.test.tsx index 9e903208e80..1d39b594b1c 100644 --- a/public/app/core/components/Form/Input.test.tsx +++ b/packages/grafana-ui/src/components/Input/Input.test.tsx @@ -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, }, diff --git a/public/app/core/components/Form/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx similarity index 66% rename from public/app/core/components/Form/Input.tsx rename to packages/grafana-ui/src/components/Input/Input.tsx index 7940f3b1104..f5f59e265c0 100644 --- a/public/app/core/components/Form/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -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 { validationEvents?: ValidationEvents; hideErrorMessage?: boolean; @@ -28,7 +15,7 @@ interface Props extends React.HTMLProps { // Override event props and append status as argument onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; onFocus?: (event: React.FocusEvent, status?: InputStatus) => void; - onChange?: (event: React.FormEvent, status?: InputStatus) => void; + onChange?: (event: React.ChangeEvent, status?: InputStatus) => void; } export class Input extends PureComponent { @@ -49,24 +36,24 @@ export class Input extends PureComponent { } validatorAsync = (validationRules: ValidationRule[]) => { - return evt => { + return (evt: ChangeEvent) => { 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) => { 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]) { diff --git a/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap b/packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap similarity index 100% rename from public/app/core/components/Form/__snapshots__/Input.test.tsx.snap rename to packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx index ea94537c429..38cd8e5c763 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx @@ -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' }, diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b8c8d66cead..e20a52f6485 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index b09d88bab4d..c0aede431d0 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -5,3 +5,4 @@ export * from './plugin'; export * from './datasource'; export * from './theme'; export * from './threshold'; +export * from './input'; diff --git a/public/app/types/form.ts b/packages/grafana-ui/src/types/input.ts similarity index 100% rename from public/app/types/form.ts rename to packages/grafana-ui/src/types/input.ts diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index 2ac38b6253a..2307f446a98 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -30,10 +30,10 @@ export interface PanelEditorProps { * Called before a panel is initalized */ export type PanelTypeChangedHook = ( - options: TOptions, + options: Partial, prevPluginId?: string, prevOptions?: any -) => TOptions; +) => Partial; export class ReactPanelPlugin { panel: ComponentClass>; diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index e2dda8ad407..bb1794a5154 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -91,6 +91,7 @@ export interface PluginMeta { includes: PluginInclude[]; // Datasource-specific + builtIn?: boolean; metrics?: boolean; tables?: boolean; logs?: boolean; diff --git a/packages/grafana-ui/src/utils/displayValue.test.ts b/packages/grafana-ui/src/utils/displayValue.test.ts new file mode 100644 index 00000000000..bf0e7a89bba --- /dev/null +++ b/packages/grafana-ui/src/utils/displayValue.test.ts @@ -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'); + }); +}); diff --git a/packages/grafana-ui/src/utils/displayValue.ts b/packages/grafana-ui/src/utils/displayValue.ts new file mode 100644 index 00000000000..75191f34434 --- /dev/null +++ b/packages/grafana-ui/src/utils/displayValue.ts @@ -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); +} diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index a08b9ce1a89..df923f19a56 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/utils/validate.ts b/packages/grafana-ui/src/utils/validate.ts new file mode 100644 index 00000000000..286ec700577 --- /dev/null +++ b/packages/grafana-ui/src/utils/validate.ts @@ -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]; +}; diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index c16c2f126f8..4ad8a2b84ab 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -110,3 +110,26 @@ func AdminDeleteUser(c *m.ReqContext) { c.JsonOK("User deleted") } + +// POST /api/admin/users/:id/logout +func (server *HTTPServer) AdminLogoutUser(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + + if c.UserId == userID { + return Error(400, "You cannot logout yourself", nil) + } + + return server.logoutUserFromAllDevicesInternal(userID) +} + +// GET /api/admin/users/:id/auth-tokens +func (server *HTTPServer) AdminGetUserAuthTokens(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + return server.getUserAuthTokensInternal(c, userID) +} + +// POST /api/admin/users/:id/revoke-auth-token +func (server *HTTPServer) AdminRevokeUserAuthToken(c *m.ReqContext, cmd m.RevokeAuthTokenCmd) Response { + userID := c.ParamsInt64(":id") + return server.revokeUserAuthTokenInternal(c, userID, cmd) +} diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 0b94a64b3fb..b94f09b0b75 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" . "github.com/smartystreets/goconvey/convey" ) @@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 400) }) }) + + Convey("When a server admin attempts to logout himself from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + adminLogoutUserScenario("Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + + Convey("When a server admin attempts to logout a non-existing user from all devices", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + adminLogoutUserScenario("Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When a server admin attempts to revoke an auth token for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + + adminRevokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When a server admin gets auth tokens for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + adminGetUserAuthTokensScenario("Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) { + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) } func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) { @@ -48,3 +105,84 @@ func putAdminScenario(desc string, url string, routePattern string, role m.RoleT fn(sc) }) } + +func adminLogoutUserScenario(desc string, url string, routePattern string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: auth.NewFakeUserAuthTokenService(), + } + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminLogoutUser(c) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func adminRevokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminRevokeUserAuthToken(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func adminGetUserAuthTokensScenario(desc string, url string, routePattern string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminGetUserAuthTokens(c) + }) + + sc.m.Get(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 81ea83eae61..f3dc35b6b06 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -133,6 +133,9 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Get("/preferences", Wrap(GetUserPreferences)) userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences)) + + userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens)) + userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) }) // users (admin permission required) @@ -375,6 +378,10 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) adminRoute.Get("/stats", AdminGetStats) adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts)) + + adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser)) + adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens)) + adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) }, reqGrafanaAdmin) // rendering diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index d77d9d87b4a..abee55711b0 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -48,18 +48,18 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) { handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)) } } - handlers = append(handlers, AppPluginRoute(route, plugin.Id)) + handlers = append(handlers, AppPluginRoute(route, plugin.Id, hs)) r.Route(url, route.Method, handlers...) log.Debug("Plugins: Adding proxy route %s", url) } } } -func AppPluginRoute(route *plugins.AppPluginRoute, appID string) macaron.Handler { +func AppPluginRoute(route *plugins.AppPluginRoute, appID string, hs *HTTPServer) macaron.Handler { return func(c *m.ReqContext) { path := c.Params("*") - proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID) + proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg) proxy.Transport = pluginProxyTransport proxy.ServeHTTP(c.Resp, c.Req.Request) } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 3f3a50aae69..4e0b0dcd998 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" @@ -94,13 +95,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string + userAuthTokenService *auth.FakeUserAuthTokenService } func (sc *scenarioContext) exec() { diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 54a744fccdc..48f9c73c934 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -31,7 +31,7 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { // macaron does not include trailing slashes when resolving a wildcard path proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*")) - proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath) + proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath, hs.Cfg) proxy.HandleRequest() } diff --git a/pkg/api/dtos/user_token.go b/pkg/api/dtos/user_token.go new file mode 100644 index 00000000000..1542421e2f6 --- /dev/null +++ b/pkg/api/dtos/user_token.go @@ -0,0 +1,12 @@ +package dtos + +import "time" + +type UserToken struct { + Id int64 `json:"id"` + IsActive bool `json:"isActive"` + ClientIp string `json:"clientIp"` + UserAgent string `json:"userAgent"` + CreatedAt time.Time `json:"createdAt"` + SeenAt time.Time `json:"seenAt"` +} diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index b1950998297..3aec988f9e3 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -34,13 +34,14 @@ type DataSourceProxy struct { proxyPath string route *plugins.AppPluginRoute plugin *plugins.DataSourcePlugin + cfg *setting.Cfg } type httpClient interface { Do(req *http.Request) (*http.Response, error) } -func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string) *DataSourceProxy { +func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string, cfg *setting.Cfg) *DataSourceProxy { targetURL, _ := url.Parse(ds.Url) return &DataSourceProxy{ @@ -49,6 +50,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx ctx: ctx, proxyPath: proxyPath, targetUrl: targetURL, + cfg: cfg, } } @@ -170,6 +172,10 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { req.Header.Add("Authorization", dsAuth) } + if proxy.cfg.SendUserHeader && !proxy.ctx.SignedInUser.IsAnonymous { + req.Header.Add("X-Grafana-User", proxy.ctx.SignedInUser.Login) + } + // clear cookie header, except for whitelisted cookies var keptCookies []*http.Cookie if proxy.ds.JsonData != nil { diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index c9be169565f..bfad7d5670d 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -81,7 +81,7 @@ func TestDSRouteRule(t *testing.T) { } Convey("When matching route path", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{}) proxy.route = plugin.Routes[0] ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds) @@ -92,7 +92,7 @@ func TestDSRouteRule(t *testing.T) { }) Convey("When matching route path and has dynamic url", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method", &setting.Cfg{}) proxy.route = plugin.Routes[3] ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds) @@ -104,20 +104,20 @@ func TestDSRouteRule(t *testing.T) { Convey("Validating request", func() { Convey("plugin route with valid role", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{}) err := proxy.validateRequest() So(err, ShouldBeNil) }) Convey("plugin route with admin role and user is editor", func() { - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{}) err := proxy.validateRequest() So(err, ShouldNotBeNil) }) Convey("plugin route with admin role and user is admin", func() { ctx.SignedInUser.OrgRole = m.ROLE_ADMIN - proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin") + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{}) err := proxy.validateRequest() So(err, ShouldBeNil) }) @@ -186,7 +186,7 @@ func TestDSRouteRule(t *testing.T) { So(err, ShouldBeNil) client = newFakeHTTPClient(json) - proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1") + proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{}) proxy1.route = plugin.Routes[0] ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds) @@ -200,7 +200,7 @@ func TestDSRouteRule(t *testing.T) { req, _ := http.NewRequest("GET", "http://localhost/asd", nil) client = newFakeHTTPClient(json2) - proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2") + proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", &setting.Cfg{}) proxy2.route = plugin.Routes[1] ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds) @@ -215,7 +215,7 @@ func TestDSRouteRule(t *testing.T) { req, _ := http.NewRequest("GET", "http://localhost/asd", nil) client = newFakeHTTPClient([]byte{}) - proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1") + proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{}) proxy3.route = plugin.Routes[0] ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds) @@ -236,7 +236,7 @@ func TestDSRouteRule(t *testing.T) { ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE} ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "/render") + proxy := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{}) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) So(err, ShouldBeNil) @@ -261,7 +261,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) So(err, ShouldBeNil) @@ -291,7 +291,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) requestURL, _ := url.Parse("http://grafana.com/sub") req := http.Request{URL: requestURL, Header: make(http.Header)} @@ -317,7 +317,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) requestURL, _ := url.Parse("http://grafana.com/sub") req := http.Request{URL: requestURL, Header: make(http.Header)} @@ -347,7 +347,7 @@ func TestDSRouteRule(t *testing.T) { } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "") + proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}) requestURL, _ := url.Parse("http://grafana.com/sub") req := http.Request{URL: requestURL, Header: make(http.Header)} @@ -369,7 +369,7 @@ func TestDSRouteRule(t *testing.T) { Url: "http://host/root/", } ctx := &m.ReqContext{} - proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/") + proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{}) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) req.Header.Add("Origin", "grafana.com") req.Header.Add("Referer", "grafana.com") @@ -388,9 +388,68 @@ func TestDSRouteRule(t *testing.T) { So(req.Header.Get("X-Canary"), ShouldEqual, "stillthere") }) }) + + Convey("When SendUserHeader config is enabled", func() { + req := getDatasourceProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: true}, + ) + Convey("Should add header with username", func() { + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user") + }) + }) + + Convey("When SendUserHeader config is disabled", func() { + req := getDatasourceProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: false}, + ) + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) + + Convey("When SendUserHeader config is enabled but user is anonymous", func() { + req := getDatasourceProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{IsAnonymous: true}, + }, + &setting.Cfg{SendUserHeader: true}, + ) + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) }) } +// getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. +func getDatasourceProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request { + plugin := &plugins.DataSourcePlugin{} + + ds := &m.DataSource{ + Type: "custom", + Url: "http://host/root/", + } + + proxy := NewDataSourceProxy(ds, plugin, ctx, "", cfg) + req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) + So(err, ShouldBeNil) + + proxy.getDirector()(req) + return req +} + type httpClientStub struct { fakeBody []byte } diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index 4cf1dbd7cde..5ee59017a82 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -2,6 +2,7 @@ package pluginproxy import ( "encoding/json" + "github.com/grafana/grafana/pkg/setting" "net" "net/http" "net/http/httputil" @@ -37,7 +38,7 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http. return result, err } -func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string) *httputil.ReverseProxy { +func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy { targetURL, _ := url.Parse(route.Url) director := func(req *http.Request) { @@ -79,6 +80,10 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl req.Header.Add("X-Grafana-Context", string(ctxJson)) + if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous { + req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login) + } + if len(route.Headers) > 0 { headers, err := getHeaders(route, ctx.OrgId, appID) if err != nil { diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index 424c3fd670c..e4a4fdb25ba 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -1,6 +1,7 @@ package pluginproxy import ( + "net/http" "testing" "github.com/grafana/grafana/pkg/bus" @@ -44,4 +45,59 @@ func TestPluginProxy(t *testing.T) { }) }) + Convey("When SendUserHeader config is enabled", t, func() { + req := getPluginProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: true}, + ) + + Convey("Should add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user") + }) + }) + + Convey("When SendUserHeader config is disabled", t, func() { + req := getPluginProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{ + Login: "test_user", + }, + }, + &setting.Cfg{SendUserHeader: false}, + ) + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) + + Convey("When SendUserHeader config is enabled but user is anonymous", t, func() { + req := getPluginProxiedRequest( + &m.ReqContext{ + SignedInUser: &m.SignedInUser{IsAnonymous: true}, + }, + &setting.Cfg{SendUserHeader: true}, + ) + + Convey("Should not add header with username", func() { + // Get will return empty string even if header is not set + So(req.Header.Get("X-Grafana-User"), ShouldEqual, "") + }) + }) +} + +// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. +func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request { + route := &plugins.AppPluginRoute{} + proxy := NewApiPluginProxy(ctx, "", route, "", cfg) + + req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) + So(err, ShouldBeNil) + proxy.Director(req) + return req } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go new file mode 100644 index 00000000000..2f74eedea5d --- /dev/null +++ b/pkg/api/user_token.go @@ -0,0 +1,110 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// GET /api/user/auth-tokens +func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response { + return server.getUserAuthTokensInternal(c, c.UserId) +} + +// POST /api/user/revoke-auth-token +func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { + return server.revokeUserAuthTokenInternal(c, c.UserId, cmd) +} + +func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Could not read user from database", err) + } + + err := server.AuthTokenService.RevokeAllUserTokens(userID) + if err != nil { + return Error(500, "Failed to logout user", err) + } + + return JSON(200, util.DynMap{ + "message": "User logged out", + }) +} + +func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Failed to get user", err) + } + + tokens, err := server.AuthTokenService.GetUserTokens(userID) + if err != nil { + return Error(500, "Failed to get user auth tokens", err) + } + + result := []*dtos.UserToken{} + for _, token := range tokens { + isActive := false + if c.UserToken != nil && c.UserToken.Id == token.Id { + isActive = true + } + + result = append(result, &dtos.UserToken{ + Id: token.Id, + IsActive: isActive, + ClientIp: token.ClientIp, + UserAgent: token.UserAgent, + CreatedAt: time.Unix(token.CreatedAt, 0), + SeenAt: time.Unix(token.SeenAt, 0), + }) + } + + return JSON(200, result) +} + +func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Failed to get user", err) + } + + token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId) + if err != nil { + if err == models.ErrUserTokenNotFound { + return Error(404, "User auth token not found", err) + } + return Error(500, "Failed to get user auth token", err) + } + + if c.UserToken != nil && c.UserToken.Id == token.Id { + return Error(400, "Cannot revoke active user auth token", nil) + } + + err = server.AuthTokenService.RevokeToken(token) + if err != nil { + if err == models.ErrUserTokenNotFound { + return Error(404, "User auth token not found", err) + } + return Error(500, "Failed to revoke user auth token", err) + } + + return JSON(200, util.DynMap{ + "message": "User auth token revoked", + }) +} diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go new file mode 100644 index 00000000000..111070dca92 --- /dev/null +++ b/pkg/api/user_token_test.go @@ -0,0 +1,294 @@ +package api + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserTokenApiEndpoint(t *testing.T) { + Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + + revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When current user gets auth tokens for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) { + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When logout an existing user from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: 200} + return nil + }) + + logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When logout a non-existing user from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + return m.ErrUserNotFound + }) + + logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("When revoke an auth token for a user", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: 200} + return nil + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &m.UserToken{Id: 1} + + revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) { + sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) { + return &m.UserToken{Id: 2}, nil + } + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When revoke the active auth token used by himself", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &m.UserToken{Id: 2} + + revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) { + sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) { + return token, nil + } + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + + Convey("When gets auth tokens for a user", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + currentToken := &m.UserToken{Id: 1} + + getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) { + tokens := []*m.UserToken{ + { + Id: 1, + ClientIp: "127.0.0.1", + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + CreatedAt: time.Now().Unix(), + SeenAt: time.Now().Unix(), + }, + { + Id: 2, + ClientIp: "127.0.0.2", + UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + CreatedAt: time.Now().Unix(), + SeenAt: time.Now().Unix(), + }, + } + sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) { + return tokens, nil + } + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + result := sc.ToJSON() + So(result.MustArray(), ShouldHaveLength, 2) + + resultOne := result.GetIndex(0) + So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id) + So(resultOne.Get("isActive").MustBool(), ShouldBeTrue) + So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1") + So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36") + So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339)) + So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339)) + + resultTwo := result.GetIndex(1) + So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id) + So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse) + So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2") + So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1") + So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339)) + So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339)) + }) + }) +} + +func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = userId + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.RevokeUserAuthToken(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = userId + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.GetUserAuthTokens(c) + }) + + sc.m.Get(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: auth.NewFakeUserAuthTokenService(), + } + + sc := setupScenarioContext("/") + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.logoutUserFromAllDevicesInternal(userId) + }) + + sc.m.Post("/", sc.defaultHandler) + + fn(sc) + }) +} + +func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext("/") + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + sc.context.UserToken = token + + return hs.revokeUserAuthTokenInternal(c, userId, cmd) + }) + + sc.m.Post("/", sc.defaultHandler) + + fn(sc) + }) +} + +func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext("/") + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + sc.context.UserToken = token + + return hs.getUserAuthTokensInternal(c, TestUserID) + }) + + sc.m.Get("/", sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 53218147ae0..c10212329cf 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -29,6 +29,7 @@ import ( // self registering services _ "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/infra/metrics" + _ "github.com/grafana/grafana/pkg/infra/remotecache" _ "github.com/grafana/grafana/pkg/infra/serverlock" _ "github.com/grafana/grafana/pkg/infra/tracing" _ "github.com/grafana/grafana/pkg/infra/usagestats" diff --git a/pkg/infra/remotecache/database_storage.go b/pkg/infra/remotecache/database_storage.go new file mode 100644 index 00000000000..1c39d74d800 --- /dev/null +++ b/pkg/infra/remotecache/database_storage.go @@ -0,0 +1,126 @@ +package remotecache + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +var getTime = time.Now + +const databaseCacheType = "database" + +type databaseCache struct { + SQLStore *sqlstore.SqlStore + log log.Logger +} + +func newDatabaseCache(sqlstore *sqlstore.SqlStore) *databaseCache { + dc := &databaseCache{ + SQLStore: sqlstore, + log: log.New("remotecache.database"), + } + + return dc +} + +func (dc *databaseCache) Run(ctx context.Context) error { + ticker := time.NewTicker(time.Minute * 10) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + dc.internalRunGC() + } + } +} + +func (dc *databaseCache) internalRunGC() { + now := getTime().Unix() + sql := `DELETE FROM cache_data WHERE (? - created_at) >= expires AND expires <> 0` + + _, err := dc.SQLStore.NewSession().Exec(sql, now) + if err != nil { + dc.log.Error("failed to run garbage collect", "error", err) + } +} + +func (dc *databaseCache) Get(key string) (interface{}, error) { + cacheHit := CacheData{} + session := dc.SQLStore.NewSession() + defer session.Close() + + exist, err := session.Where("cache_key= ?", key).Get(&cacheHit) + + if err != nil { + return nil, err + } + + if !exist { + return nil, ErrCacheItemNotFound + } + + if cacheHit.Expires > 0 { + existedButExpired := getTime().Unix()-cacheHit.CreatedAt >= cacheHit.Expires + if existedButExpired { + _ = dc.Delete(key) //ignore this error since we will return `ErrCacheItemNotFound` anyway + return nil, ErrCacheItemNotFound + } + } + + item := &cachedItem{} + if err = decodeGob(cacheHit.Data, item); err != nil { + return nil, err + } + + return item.Val, nil +} + +func (dc *databaseCache) Set(key string, value interface{}, expire time.Duration) error { + item := &cachedItem{Val: value} + data, err := encodeGob(item) + if err != nil { + return err + } + + session := dc.SQLStore.NewSession() + + var cacheHit CacheData + has, err := session.Where("cache_key = ?", key).Get(&cacheHit) + if err != nil { + return err + } + + var expiresInSeconds int64 + if expire != 0 { + expiresInSeconds = int64(expire) / int64(time.Second) + } + + // insert or update depending on if item already exist + if has { + sql := `UPDATE cache_data SET data=?, created=?, expire=? WHERE cache_key='?'` + _, err = session.Exec(sql, data, getTime().Unix(), expiresInSeconds, key) + } else { + sql := `INSERT INTO cache_data (cache_key,data,created_at,expires) VALUES(?,?,?,?)` + _, err = session.Exec(sql, key, data, getTime().Unix(), expiresInSeconds) + } + + return err +} + +func (dc *databaseCache) Delete(key string) error { + sql := "DELETE FROM cache_data WHERE cache_key=?" + _, err := dc.SQLStore.NewSession().Exec(sql, key) + + return err +} + +type CacheData struct { + CacheKey string + Data []byte + Expires int64 + CreatedAt int64 +} diff --git a/pkg/infra/remotecache/database_storage_test.go b/pkg/infra/remotecache/database_storage_test.go new file mode 100644 index 00000000000..d15e26fd07f --- /dev/null +++ b/pkg/infra/remotecache/database_storage_test.go @@ -0,0 +1,56 @@ +package remotecache + +import ( + "testing" + "time" + + "github.com/bmizerany/assert" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func TestDatabaseStorageGarbageCollection(t *testing.T) { + sqlstore := sqlstore.InitTestDB(t) + + db := &databaseCache{ + SQLStore: sqlstore, + log: log.New("remotecache.database"), + } + + obj := &CacheableStruct{String: "foolbar"} + + //set time.now to 2 weeks ago + var err error + getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } + err = db.Set("key1", obj, 1000*time.Second) + assert.Equal(t, err, nil) + + err = db.Set("key2", obj, 1000*time.Second) + assert.Equal(t, err, nil) + + err = db.Set("key3", obj, 1000*time.Second) + assert.Equal(t, err, nil) + + // insert object that should never expire + db.Set("key4", obj, 0) + + getTime = time.Now + db.Set("key5", obj, 1000*time.Second) + + //run GC + db.internalRunGC() + + //try to read values + _, err = db.Get("key1") + assert.Equal(t, err, ErrCacheItemNotFound, "expected cache item not found. got: ", err) + _, err = db.Get("key2") + assert.Equal(t, err, ErrCacheItemNotFound) + _, err = db.Get("key3") + assert.Equal(t, err, ErrCacheItemNotFound) + + _, err = db.Get("key4") + assert.Equal(t, err, nil) + _, err = db.Get("key5") + assert.Equal(t, err, nil) +} diff --git a/pkg/infra/remotecache/memcached_storage.go b/pkg/infra/remotecache/memcached_storage.go new file mode 100644 index 00000000000..5424a05ad02 --- /dev/null +++ b/pkg/infra/remotecache/memcached_storage.go @@ -0,0 +1,71 @@ +package remotecache + +import ( + "time" + + "github.com/bradfitz/gomemcache/memcache" + "github.com/grafana/grafana/pkg/setting" +) + +const memcachedCacheType = "memcached" + +type memcachedStorage struct { + c *memcache.Client +} + +func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage { + return &memcachedStorage{ + c: memcache.New(opts.ConnStr), + } +} + +func newItem(sid string, data []byte, expire int32) *memcache.Item { + return &memcache.Item{ + Key: sid, + Value: data, + Expiration: expire, + } +} + +// Set sets value to given key in the cache. +func (s *memcachedStorage) Set(key string, val interface{}, expires time.Duration) error { + item := &cachedItem{Val: val} + bytes, err := encodeGob(item) + if err != nil { + return err + } + + var expiresInSeconds int64 + if expires != 0 { + expiresInSeconds = int64(expires) / int64(time.Second) + } + + memcachedItem := newItem(key, bytes, int32(expiresInSeconds)) + return s.c.Set(memcachedItem) +} + +// Get gets value by given key in the cache. +func (s *memcachedStorage) Get(key string) (interface{}, error) { + memcachedItem, err := s.c.Get(key) + if err != nil && err.Error() == "memcache: cache miss" { + return nil, ErrCacheItemNotFound + } + + if err != nil { + return nil, err + } + + item := &cachedItem{} + + err = decodeGob(memcachedItem.Value, item) + if err != nil { + return nil, err + } + + return item.Val, nil +} + +// Delete delete a key from the cache +func (s *memcachedStorage) Delete(key string) error { + return s.c.Delete(key) +} diff --git a/pkg/infra/remotecache/memcached_storage_integration_test.go b/pkg/infra/remotecache/memcached_storage_integration_test.go new file mode 100644 index 00000000000..d1d82468644 --- /dev/null +++ b/pkg/infra/remotecache/memcached_storage_integration_test.go @@ -0,0 +1,15 @@ +// +build memcached + +package remotecache + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" +) + +func TestMemcachedCacheStorage(t *testing.T) { + opts := &setting.RemoteCacheOptions{Name: memcachedCacheType, ConnStr: "localhost:11211"} + client := createTestClient(t, opts, nil) + runTestsForClient(t, client) +} diff --git a/pkg/infra/remotecache/redis_storage.go b/pkg/infra/remotecache/redis_storage.go new file mode 100644 index 00000000000..bd54b843119 --- /dev/null +++ b/pkg/infra/remotecache/redis_storage.go @@ -0,0 +1,62 @@ +package remotecache + +import ( + "time" + + "github.com/grafana/grafana/pkg/setting" + redis "gopkg.in/redis.v2" +) + +const redisCacheType = "redis" + +type redisStorage struct { + c *redis.Client +} + +func newRedisStorage(opts *setting.RemoteCacheOptions) *redisStorage { + opt := &redis.Options{ + Network: "tcp", + Addr: opts.ConnStr, + } + return &redisStorage{c: redis.NewClient(opt)} +} + +// Set sets value to given key in session. +func (s *redisStorage) Set(key string, val interface{}, expires time.Duration) error { + item := &cachedItem{Val: val} + value, err := encodeGob(item) + if err != nil { + return err + } + + status := s.c.SetEx(key, expires, string(value)) + return status.Err() +} + +// Get gets value by given key in session. +func (s *redisStorage) Get(key string) (interface{}, error) { + v := s.c.Get(key) + + item := &cachedItem{} + err := decodeGob([]byte(v.Val()), item) + + if err == nil { + return item.Val, nil + } + + if err.Error() == "EOF" { + return nil, ErrCacheItemNotFound + } + + if err != nil { + return nil, err + } + + return item.Val, nil +} + +// Delete delete a key from session. +func (s *redisStorage) Delete(key string) error { + cmd := s.c.Del(key) + return cmd.Err() +} diff --git a/pkg/infra/remotecache/redis_storage_integration_test.go b/pkg/infra/remotecache/redis_storage_integration_test.go new file mode 100644 index 00000000000..8d54fc9ff14 --- /dev/null +++ b/pkg/infra/remotecache/redis_storage_integration_test.go @@ -0,0 +1,16 @@ +// +build redis + +package remotecache + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" +) + +func TestRedisCacheStorage(t *testing.T) { + + opts := &setting.RemoteCacheOptions{Name: redisCacheType, ConnStr: "localhost:6379"} + client := createTestClient(t, opts, nil) + runTestsForClient(t, client) +} diff --git a/pkg/infra/remotecache/remotecache.go b/pkg/infra/remotecache/remotecache.go new file mode 100644 index 00000000000..9219fa33a08 --- /dev/null +++ b/pkg/infra/remotecache/remotecache.go @@ -0,0 +1,133 @@ +package remotecache + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "time" + + "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + + "github.com/grafana/grafana/pkg/registry" +) + +var ( + // ErrCacheItemNotFound is returned if cache does not exist + ErrCacheItemNotFound = errors.New("cache item not found") + + // ErrInvalidCacheType is returned if the type is invalid + ErrInvalidCacheType = errors.New("invalid remote cache name") + + defaultMaxCacheExpiration = time.Hour * 24 +) + +func init() { + registry.RegisterService(&RemoteCache{}) +} + +// CacheStorage allows the caller to set, get and delete items in the cache. +// Cached items are stored as byte arrays and marshalled using "encoding/gob" +// so any struct added to the cache needs to be registred with `remotecache.Register` +// ex `remotecache.Register(CacheableStruct{})`` +type CacheStorage interface { + // Get reads object from Cache + Get(key string) (interface{}, error) + + // Set sets an object into the cache. if `expire` is set to zero it will default to 24h + Set(key string, value interface{}, expire time.Duration) error + + // Delete object from cache + Delete(key string) error +} + +// RemoteCache allows Grafana to cache data outside its own process +type RemoteCache struct { + log log.Logger + client CacheStorage + SQLStore *sqlstore.SqlStore `inject:""` + Cfg *setting.Cfg `inject:""` +} + +// Get reads object from Cache +func (ds *RemoteCache) Get(key string) (interface{}, error) { + return ds.client.Get(key) +} + +// Set sets an object into the cache. if `expire` is set to zero it will default to 24h +func (ds *RemoteCache) Set(key string, value interface{}, expire time.Duration) error { + if expire == 0 { + expire = defaultMaxCacheExpiration + } + + return ds.client.Set(key, value, expire) +} + +// Delete object from cache +func (ds *RemoteCache) Delete(key string) error { + return ds.client.Delete(key) +} + +// Init initializes the service +func (ds *RemoteCache) Init() error { + ds.log = log.New("cache.remote") + var err error + ds.client, err = createClient(ds.Cfg.RemoteCacheOptions, ds.SQLStore) + return err +} + +// Run start the backend processes for cache clients +func (ds *RemoteCache) Run(ctx context.Context) error { + //create new interface if more clients need GC jobs + backgroundjob, ok := ds.client.(registry.BackgroundService) + if ok { + return backgroundjob.Run(ctx) + } + + <-ctx.Done() + return ctx.Err() +} + +func createClient(opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) (CacheStorage, error) { + if opts.Name == redisCacheType { + return newRedisStorage(opts), nil + } + + if opts.Name == memcachedCacheType { + return newMemcachedStorage(opts), nil + } + + if opts.Name == databaseCacheType { + return newDatabaseCache(sqlstore), nil + } + + return nil, ErrInvalidCacheType +} + +// Register records a type, identified by a value for that type, under its +// internal type name. That name will identify the concrete type of a value +// sent or received as an interface variable. Only types that will be +// transferred as implementations of interface values need to be registered. +// Expecting to be used only during initialization, it panics if the mapping +// between types and names is not a bijection. +func Register(value interface{}) { + gob.Register(value) +} + +type cachedItem struct { + Val interface{} +} + +func encodeGob(item *cachedItem) ([]byte, error) { + buf := bytes.NewBuffer(nil) + err := gob.NewEncoder(buf).Encode(item) + return buf.Bytes(), err +} + +func decodeGob(data []byte, out *cachedItem) error { + buf := bytes.NewBuffer(data) + return gob.NewDecoder(buf).Decode(&out) +} diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go new file mode 100644 index 00000000000..bf1675ec87c --- /dev/null +++ b/pkg/infra/remotecache/remotecache_test.go @@ -0,0 +1,93 @@ +package remotecache + +import ( + "testing" + "time" + + "github.com/bmizerany/assert" + + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +type CacheableStruct struct { + String string + Int64 int64 +} + +func init() { + Register(CacheableStruct{}) +} + +func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) CacheStorage { + t.Helper() + + dc := &RemoteCache{ + SQLStore: sqlstore, + Cfg: &setting.Cfg{ + RemoteCacheOptions: opts, + }, + } + + err := dc.Init() + if err != nil { + t.Fatalf("failed to init client for test. error: %v", err) + } + + return dc +} + +func TestCachedBasedOnConfig(t *testing.T) { + + cfg := setting.NewCfg() + cfg.Load(&setting.CommandLineArgs{ + HomePath: "../../../", + }) + + client := createTestClient(t, cfg.RemoteCacheOptions, sqlstore.InitTestDB(t)) + runTestsForClient(t, client) +} + +func TestInvalidCacheTypeReturnsError(t *testing.T) { + _, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil) + assert.Equal(t, err, ErrInvalidCacheType) +} + +func runTestsForClient(t *testing.T, client CacheStorage) { + canPutGetAndDeleteCachedObjects(t, client) + canNotFetchExpiredItems(t, client) +} + +func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) { + cacheableStruct := CacheableStruct{String: "hej", Int64: 2000} + + err := client.Set("key1", cacheableStruct, 0) + assert.Equal(t, err, nil, "expected nil. got: ", err) + + data, err := client.Get("key1") + s, ok := data.(CacheableStruct) + + assert.Equal(t, ok, true) + assert.Equal(t, s.String, "hej") + assert.Equal(t, s.Int64, int64(2000)) + + err = client.Delete("key1") + assert.Equal(t, err, nil) + + _, err = client.Get("key1") + assert.Equal(t, err, ErrCacheItemNotFound) +} + +func canNotFetchExpiredItems(t *testing.T, client CacheStorage) { + cacheableStruct := CacheableStruct{String: "hej", Int64: 2000} + + err := client.Set("key1", cacheableStruct, time.Second) + assert.Equal(t, err, nil) + + //not sure how this can be avoided when testing redis/memcached :/ + <-time.After(time.Second + time.Millisecond) + + // should not be able to read that value since its expired + _, err = client.Get("key1") + assert.Equal(t, err, ErrCacheItemNotFound) +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 1fbd303bebd..2fc8e0c456f 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -11,6 +11,7 @@ import ( msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: unhashedToken, @@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", }, nil } - sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { + sc.userAuthTokenService.TryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil } @@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return nil, m.ErrUserTokenNotFound } @@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { })) session.Init(&msession.Options{}, 0) - sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} @@ -595,7 +596,7 @@ type scenarioContext struct { handlerFunc handlerFunc defaultHandler macaron.Handler url string - userAuthTokenService *fakeUserAuthTokenService + userAuthTokenService *auth.FakeUserAuthTokenService req *http.Request } @@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) - -type fakeUserAuthTokenService struct { - createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error) - tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error) - lookupTokenProvider func(unhashedToken string) (*m.UserToken, error) - revokeTokenProvider func(token *m.UserToken) error - activeAuthTokenCount func() (int64, error) -} - -func newFakeUserAuthTokenService() *fakeUserAuthTokenService { - return &fakeUserAuthTokenService{ - createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) { - return &m.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - }, - tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) { - return false, nil - }, - lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - }, - revokeTokenProvider: func(token *m.UserToken) error { - return nil - }, - activeAuthTokenCount: func() (int64, error) { - return 10, nil - }, - } -} - -func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) { - return s.createTokenProvider(userId, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) { - return s.lookupTokenProvider(unhashedToken) -} - -func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) { - return s.tryRotateTokenProvider(token, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error { - return s.revokeTokenProvider(token) -} - -func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) { - return s.activeAuthTokenCount() -} diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index e01d1a68d21..fe5b2736035 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 0, UnhashedToken: "", @@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 52b696cf037..0ba42e708bc 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -3,6 +3,7 @@ package middleware import ( "testing" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/bus" @@ -36,7 +37,7 @@ func TestMiddlewareQuota(t *testing.T) { }, } - fakeAuthTokenService := newFakeUserAuthTokenService() + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() qs := "a.QuotaService{ AuthTokenService: fakeAuthTokenService, } @@ -87,7 +88,7 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 6736d699a39..00f3b7a3032 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" macaron "gopkg.in/macaron.v1" @@ -62,7 +63,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine sc.m.Use(OrgRedirect()) diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 388bc2dd4a2..8c3e7985995 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -1,6 +1,8 @@ package models -import "errors" +import ( + "errors" +) // Typed errors var ( @@ -23,11 +25,18 @@ type UserToken struct { UnhashedToken string } +type RevokeAuthTokenCmd struct { + AuthTokenId int64 `json:"authTokenId"` +} + // UserTokenService are used for generating and validating user tokens type UserTokenService interface { CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error) LookupToken(unhashedToken string) (*UserToken, error) TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error) RevokeToken(token *UserToken) error + RevokeAllUserTokens(userId int64) error ActiveTokenCount() (int64, error) + GetUserToken(userId, userTokenId int64) (*UserToken, error) + GetUserTokens(userId int64) ([]*UserToken, error) } diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index 648575d54cd..255866a9ba0 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -221,6 +221,57 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error { return nil } +func (s *UserAuthTokenService) RevokeAllUserTokens(userId int64) error { + sql := `DELETE from user_auth_token WHERE user_id = ?` + res, err := s.SQLStore.NewSession().Exec(sql, userId) + if err != nil { + return err + } + + affected, err := res.RowsAffected() + if err != nil { + return err + } + + s.log.Debug("all user tokens for user revoked", "userId", userId, "count", affected) + + return nil +} + +func (s *UserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) { + var token userAuthToken + exists, err := s.SQLStore.NewSession().Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token) + if err != nil { + return nil, err + } + + if !exists { + return nil, models.ErrUserTokenNotFound + } + + var result models.UserToken + token.toUserToken(&result) + + return &result, nil +} + +func (s *UserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) { + var tokens []*userAuthToken + err := s.SQLStore.NewSession().Where("user_id = ? AND created_at > ? AND rotated_at > ?", userId, s.createdAfterParam(), s.rotatedAfterParam()).Find(&tokens) + if err != nil { + return nil, err + } + + result := []*models.UserToken{} + for _, token := range tokens { + var userToken models.UserToken + token.toUserToken(&userToken) + result = append(result, &userToken) + } + + return result, nil +} + func (s *UserAuthTokenService) createdAfterParam() int64 { tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour return getTime().Add(-tokenMaxLifetime).Unix() diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 49e7acc3a5b..33eb309ad18 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -75,6 +75,47 @@ func TestUserAuthToken(t *testing.T) { err = userAuthTokenService.RevokeToken(userToken) So(err, ShouldEqual, models.ErrUserTokenNotFound) }) + + Convey("When creating an additional token", func() { + userToken2, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(userToken2, ShouldNotBeNil) + + Convey("Can get first user token", func() { + token, err := userAuthTokenService.GetUserToken(userID, userToken.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.Id, ShouldEqual, userToken.Id) + }) + + Convey("Can get second user token", func() { + token, err := userAuthTokenService.GetUserToken(userID, userToken2.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.Id, ShouldEqual, userToken2.Id) + }) + + Convey("Can get user tokens", func() { + tokens, err := userAuthTokenService.GetUserTokens(userID) + So(err, ShouldBeNil) + So(tokens, ShouldHaveLength, 2) + So(tokens[0].Id, ShouldEqual, userToken.Id) + So(tokens[1].Id, ShouldEqual, userToken2.Id) + }) + + Convey("Can revoke all user tokens", func() { + err := userAuthTokenService.RevokeAllUserTokens(userID) + So(err, ShouldBeNil) + + model, err := ctx.getAuthTokenByID(userToken.Id) + So(err, ShouldBeNil) + So(model, ShouldBeNil) + + model2, err := ctx.getAuthTokenByID(userToken2.Id) + So(err, ShouldBeNil) + So(model2, ShouldBeNil) + }) + }) }) Convey("expires correctly", func() { diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/testing.go new file mode 100644 index 00000000000..68e65466c3d --- /dev/null +++ b/pkg/services/auth/testing.go @@ -0,0 +1,81 @@ +package auth + +import "github.com/grafana/grafana/pkg/models" + +type FakeUserAuthTokenService struct { + CreateTokenProvider func(userId int64, clientIP, userAgent string) (*models.UserToken, error) + TryRotateTokenProvider func(token *models.UserToken, clientIP, userAgent string) (bool, error) + LookupTokenProvider func(unhashedToken string) (*models.UserToken, error) + RevokeTokenProvider func(token *models.UserToken) error + RevokeAllUserTokensProvider func(userId int64) error + ActiveAuthTokenCount func() (int64, error) + GetUserTokenProvider func(userId, userTokenId int64) (*models.UserToken, error) + GetUserTokensProvider func(userId int64) ([]*models.UserToken, error) +} + +func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { + return &FakeUserAuthTokenService{ + CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + }, + TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + }, + RevokeTokenProvider: func(token *models.UserToken) error { + return nil + }, + RevokeAllUserTokensProvider: func(userId int64) error { + return nil + }, + ActiveAuthTokenCount: func() (int64, error) { + return 10, nil + }, + GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) { + return nil, nil + }, + GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) { + return nil, nil + }, + } +} + +func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { + return s.CreateTokenProvider(userId, clientIP, userAgent) +} + +func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) { + return s.LookupTokenProvider(unhashedToken) +} + +func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) { + return s.TryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error { + return s.RevokeTokenProvider(token) +} + +func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error { + return s.RevokeAllUserTokensProvider(userId) +} + +func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) { + return s.ActiveAuthTokenCount() +} + +func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) { + return s.GetUserTokenProvider(userId, userTokenId) +} + +func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) { + return s.GetUserTokensProvider(userId) +} diff --git a/pkg/services/sqlstore/migrations/cache_data_mig.go b/pkg/services/sqlstore/migrations/cache_data_mig.go new file mode 100644 index 00000000000..3467b88962b --- /dev/null +++ b/pkg/services/sqlstore/migrations/cache_data_mig.go @@ -0,0 +1,22 @@ +package migrations + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addCacheMigration(mg *migrator.Migrator) { + var cacheDataV1 = migrator.Table{ + Name: "cache_data", + Columns: []*migrator.Column{ + {Name: "cache_key", Type: migrator.DB_NVarchar, IsPrimaryKey: true, Length: 168}, + {Name: "data", Type: migrator.DB_Blob}, + {Name: "expires", Type: migrator.DB_Integer, Length: 255, Nullable: false}, + {Name: "created_at", Type: migrator.DB_Integer, Length: 255, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"cache_key"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create cache_data table", migrator.NewAddTableMigration(cacheDataV1)) + + mg.AddMigration("add unique index cache_data.cache_key", migrator.NewAddIndexMigration(cacheDataV1, cacheDataV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 931259ec3ed..3e40c749f37 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -33,6 +33,7 @@ func AddMigrations(mg *Migrator) { addUserAuthMigrations(mg) addServerlockMigrations(mg) addUserAuthTokenMigrations(mg) + addCacheMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 7b6e99255aa..bc57291b5f9 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -241,6 +241,12 @@ type Cfg struct { // User EditorsCanOwn bool + + // Dataproxy + SendUserHeader bool + + // DistributedCache + RemoteCacheOptions *RemoteCacheOptions } type CommandLineArgs struct { @@ -601,6 +607,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { dataproxy := iniFile.Section("dataproxy") DataProxyLogging = dataproxy.Key("logging").MustBool(false) DataProxyTimeout = dataproxy.Key("timeout").MustInt(30) + cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false) // read security settings security := iniFile.Section("security") @@ -781,9 +788,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { enterprise := iniFile.Section("enterprise") cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt")) + cacheServer := iniFile.Section("remote_cache") + cfg.RemoteCacheOptions = &RemoteCacheOptions{ + Name: cacheServer.Key("type").MustString("database"), + ConnStr: cacheServer.Key("connstr").MustString(""), + } + return nil } +type RemoteCacheOptions struct { + Name string + ConnStr string +} + func (cfg *Cfg) readSessionConfig() { sec := cfg.Raw.Section("session") SessionOptions = session.Options{} diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 12f2b6c03c9..c740d6cbe77 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -3,6 +3,7 @@ package mssql import ( "database/sql" "fmt" + "github.com/grafana/grafana/pkg/setting" "strconv" _ "github.com/denisenkom/go-mssqldb" @@ -24,7 +25,9 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin if err != nil { return nil, err } - logger.Debug("getEngine", "connection", cnnstr) + if setting.Env == setting.DEV { + logger.Debug("getEngine", "connection", cnnstr) + } config := tsdb.SqlQueryEndpointConfiguration{ DriverName: "mssql", diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index d307e12166c..0451f8f0dc1 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -3,6 +3,7 @@ package mysql import ( "database/sql" "fmt" + "github.com/grafana/grafana/pkg/setting" "reflect" "strconv" "strings" @@ -44,7 +45,9 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin cnnstr += "&tls=" + tlsConfigString } - logger.Debug("getEngine", "connection", cnnstr) + if setting.Env == setting.DEV { + logger.Debug("getEngine", "connection", cnnstr) + } config := tsdb.SqlQueryEndpointConfiguration{ DriverName: "mysql", diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index 4bcf06638f4..ae6b165e731 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -2,6 +2,7 @@ package postgres import ( "database/sql" + "github.com/grafana/grafana/pkg/setting" "net/url" "strconv" @@ -19,7 +20,9 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp logger := log.New("tsdb.postgres") cnnstr := generateConnectionString(datasource) - logger.Debug("getEngine", "connection", cnnstr) + if setting.Env == setting.DEV { + logger.Debug("getEngine", "connection", cnnstr) + } config := tsdb.SqlQueryEndpointConfiguration{ DriverName: "postgres", diff --git a/pkg/tsdb/testdata/scenarios.go b/pkg/tsdb/testdata/scenarios.go index 421a907b5e9..4780dd6e662 100644 --- a/pkg/tsdb/testdata/scenarios.go +++ b/pkg/tsdb/testdata/scenarios.go @@ -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 } diff --git a/pkg/tsdb/testdata/scenarios_test.go b/pkg/tsdb/testdata/scenarios_test.go new file mode 100644 index 00000000000..307f5e4affe --- /dev/null +++ b/pkg/tsdb/testdata/scenarios_test.go @@ -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) + }) + }) +} diff --git a/public/app/app.ts b/public/app/app.ts index 03f332e357b..a54c9270e2c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -17,12 +17,13 @@ import 'vendor/angular-other/angular-strap'; import $ from 'jquery'; import angular from 'angular'; import config from 'app/core/config'; +// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move import _ from 'lodash'; import moment from 'moment'; import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar'; // add move to lodash for backward compatabiltiy -_.move = (array, fromIndex, toIndex) => { +_.move = (array: [], fromIndex: number, toIndex: number) => { array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); return array; }; @@ -36,7 +37,7 @@ import 'app/features/all'; // import symlinked extensions const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); -extensionsIndex.keys().forEach(key => { +extensionsIndex.keys().forEach((key: any) => { extensionsIndex(key); }); @@ -52,7 +53,7 @@ export class GrafanaApp { this.ngModuleDependencies = []; } - useModule(module) { + useModule(module: angular.IModule) { if (this.preBootModules) { this.preBootModules.push(module); } else { @@ -67,40 +68,49 @@ export class GrafanaApp { moment.locale(config.bootData.user.locale); - app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => { - // pre assing bindings before constructor calls - $compileProvider.preAssignBindingsEnabled(true); + app.config( + ( + $locationProvider: angular.ILocationProvider, + $controllerProvider: angular.IControllerProvider, + $compileProvider: angular.ICompileProvider, + $filterProvider: angular.IFilterProvider, + $httpProvider: angular.IHttpProvider, + $provide: angular.auto.IProvideService + ) => { + // pre assing bindings before constructor calls + $compileProvider.preAssignBindingsEnabled(true); - if (config.buildInfo.env !== 'development') { - $compileProvider.debugInfoEnabled(false); - } + if (config.buildInfo.env !== 'development') { + $compileProvider.debugInfoEnabled(false); + } - $httpProvider.useApplyAsync(true); + $httpProvider.useApplyAsync(true); - this.registerFunctions.controller = $controllerProvider.register; - this.registerFunctions.directive = $compileProvider.directive; - this.registerFunctions.factory = $provide.factory; - this.registerFunctions.service = $provide.service; - this.registerFunctions.filter = $filterProvider.register; + this.registerFunctions.controller = $controllerProvider.register; + this.registerFunctions.directive = $compileProvider.directive; + this.registerFunctions.factory = $provide.factory; + this.registerFunctions.service = $provide.service; + this.registerFunctions.filter = $filterProvider.register; - $provide.decorator('$http', [ - '$delegate', - '$templateCache', - ($delegate, $templateCache) => { - const get = $delegate.get; - $delegate.get = (url, config) => { - if (url.match(/\.html$/)) { - // some template's already exist in the cache - if (!$templateCache.get(url)) { - url += '?v=' + new Date().getTime(); + $provide.decorator('$http', [ + '$delegate', + '$templateCache', + ($delegate: any, $templateCache: any) => { + const get = $delegate.get; + $delegate.get = (url: string, config: any) => { + if (url.match(/\.html$/)) { + // some template's already exist in the cache + if (!$templateCache.get(url)) { + url += '?v=' + new Date().getTime(); + } } - } - return get(url, config); - }; - return $delegate; - }, - ]); - }); + return get(url, config); + }; + return $delegate; + }, + ]); + } + ); this.ngModuleDependencies = [ 'grafana.core', @@ -116,7 +126,7 @@ export class GrafanaApp { ]; // makes it possible to add dynamic stuff - _.each(angularModules, m => { + _.each(angularModules, (m: angular.IModule) => { this.useModule(m); }); @@ -129,7 +139,7 @@ export class GrafanaApp { // bootstrap the app angular.bootstrap(document, this.ngModuleDependencies).invoke(() => { - _.each(this.preBootModules, module => { + _.each(this.preBootModules, (module: angular.IModule) => { _.extend(module, this.registerFunctions); }); diff --git a/public/app/core/components/Form/index.ts b/public/app/core/components/Form/index.ts deleted file mode 100644 index 6322cf3241a..00000000000 --- a/public/app/core/components/Form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Input } from './Input'; diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index 25a69b1f5e4..3f6dacd311d 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -1,7 +1,30 @@ +// @ts-ignore import _ from 'lodash'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; import { SearchSrv } from 'app/core/services/search_srv'; +import { BackendSrv } from 'app/core/services/backend_srv'; +import { NavModelSrv } from 'app/core/nav_model_srv'; +import { ContextSrv } from 'app/core/services/context_srv'; + +export interface Section { + id: number; + uid: string; + title: string; + expanded: false; + items: any[]; + url: string; + icon: string; + score: number; + checked: boolean; + hideHeader: boolean; + toggle: Function; +} + +export interface FoldersAndDashboardUids { + folderUids: string[]; + dashboardUids: string[]; +} class Query { query: string; @@ -14,7 +37,7 @@ class Query { } export class ManageDashboardsCtrl { - sections: any[]; + sections: Section[]; query: Query; navModel: any; @@ -45,7 +68,12 @@ export class ManageDashboardsCtrl { hasEditPermissionInFolders: boolean; /** @ngInject */ - constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) { + constructor( + private backendSrv: BackendSrv, + navModelSrv: NavModelSrv, + private searchSrv: SearchSrv, + private contextSrv: ContextSrv + ) { this.isEditor = this.contextSrv.isEditor; this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders; @@ -73,7 +101,7 @@ export class ManageDashboardsCtrl { refreshList() { return this.searchSrv .search(this.query) - .then(result => { + .then((result: Section[]) => { return this.initDashboardList(result); }) .then(() => { @@ -81,7 +109,7 @@ export class ManageDashboardsCtrl { return; } - return this.backendSrv.getFolderByUid(this.folderUid).then(folder => { + return this.backendSrv.getFolderByUid(this.folderUid).then((folder: any) => { this.canSave = folder.canSave; if (!this.canSave) { this.hasEditPermissionInFolders = false; @@ -90,7 +118,7 @@ export class ManageDashboardsCtrl { }); } - initDashboardList(result: any) { + initDashboardList(result: Section[]) { this.canMove = false; this.canDelete = false; this.selectAllChecked = false; @@ -128,25 +156,25 @@ export class ManageDashboardsCtrl { this.canDelete = selectedDashboards > 0 || selectedFolders > 0; } - getFoldersAndDashboardsToDelete() { - const selectedDashboards = { - folders: [], - dashboards: [], + getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids { + const selectedDashboards: FoldersAndDashboardUids = { + folderUids: [], + dashboardUids: [], }; for (const section of this.sections) { if (section.checked && section.id !== 0) { - selectedDashboards.folders.push(section.uid); + selectedDashboards.folderUids.push(section.uid); } else { const selected = _.filter(section.items, { checked: true }); - selectedDashboards.dashboards.push(..._.map(selected, 'uid')); + selectedDashboards.dashboardUids.push(..._.map(selected, 'uid')); } } return selectedDashboards; } - getFolderIds(sections) { + getFolderIds(sections: Section[]) { const ids = []; for (const s of sections) { if (s.checked) { @@ -158,8 +186,8 @@ export class ManageDashboardsCtrl { delete() { const data = this.getFoldersAndDashboardsToDelete(); - const folderCount = data.folders.length; - const dashCount = data.dashboards.length; + const folderCount = data.folderUids.length; + const dashCount = data.dashboardUids.length; let text = 'Do you want to delete the '; let text2; @@ -179,12 +207,12 @@ export class ManageDashboardsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - this.deleteFoldersAndDashboards(data.folders, data.dashboards); + this.deleteFoldersAndDashboards(data.folderUids, data.dashboardUids); }, }); } - private deleteFoldersAndDashboards(folderUids, dashboardUids) { + private deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) { this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => { this.refreshList(); }); @@ -219,13 +247,13 @@ export class ManageDashboardsCtrl { } initTagFilter() { - return this.searchSrv.getDashboardTags().then(results => { + return this.searchSrv.getDashboardTags().then((results: any) => { this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results); this.selectedTagFilter = this.tagFilterOptions[0]; }); } - filterByTag(tag) { + filterByTag(tag: any) { if (_.indexOf(this.query.tag, tag) === -1) { this.query.tag.push(tag); } @@ -243,7 +271,7 @@ export class ManageDashboardsCtrl { return res; } - removeTag(tag, evt) { + removeTag(tag: any, evt: Event) { this.query.tag = _.without(this.query.tag, tag); this.refreshList(); if (evt) { @@ -269,7 +297,7 @@ export class ManageDashboardsCtrl { section.checked = this.selectAllChecked; } - section.items = _.map(section.items, item => { + section.items = _.map(section.items, (item: any) => { item.checked = this.selectAllChecked; return item; }); diff --git a/public/app/core/config.ts b/public/app/core/config.ts index bbf7fb88d62..9789888e60f 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -13,7 +13,7 @@ export interface BuildInfo { export class Settings { datasources: any; - panels: PanelPlugin[]; + panels: { [key: string]: PanelPlugin }; appSubUrl: string; windowTitlePrefix: string; buildInfo: BuildInfo; diff --git a/public/app/core/specs/manage_dashboards.test.ts b/public/app/core/specs/manage_dashboards.test.ts index 5af0ebade02..ef5e240fd36 100644 --- a/public/app/core/specs/manage_dashboards.test.ts +++ b/public/app/core/specs/manage_dashboards.test.ts @@ -1,12 +1,39 @@ -import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards'; -import { SearchSrv } from 'app/core/services/search_srv'; +// @ts-ignore import q from 'q'; +import { + ManageDashboardsCtrl, + Section, + FoldersAndDashboardUids, +} from 'app/core/components/manage_dashboards/manage_dashboards'; +import { SearchSrv } from 'app/core/services/search_srv'; +import { BackendSrv } from '../services/backend_srv'; +import { NavModelSrv } from '../nav_model_srv'; +import { ContextSrv } from '../services/context_srv'; + +const mockSection = (overides?: object): Section => { + const defaultSection: Section = { + id: 0, + items: [], + checked: false, + expanded: false, + hideHeader: false, + icon: '', + score: 0, + title: 'Some Section', + toggle: jest.fn(), + uid: 'someuid', + url: '/some/url/', + }; + + return { ...defaultSection, ...overides }; +}; describe('ManageDashboards', () => { - let ctrl; + let ctrl: ManageDashboardsCtrl; describe('when browsing dashboards', () => { beforeEach(() => { + const tags: any[] = []; const response = [ { id: 410, @@ -18,11 +45,11 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, }, ], - tags: [], + tags, isStarred: false, }, { @@ -37,11 +64,11 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, }, ], - tags: [], + tags, isStarred: false, }, ]; @@ -61,6 +88,7 @@ describe('ManageDashboards', () => { describe('when browsing dashboards for a folder', () => { beforeEach(() => { + const tags: any[] = []; const response = [ { id: 410, @@ -72,11 +100,11 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, }, ], - tags: [], + tags, isStarred: false, }, ]; @@ -92,6 +120,7 @@ describe('ManageDashboards', () => { describe('when searching dashboards', () => { beforeEach(() => { + const tags: any[] = []; const response = [ { checked: false, @@ -103,7 +132,7 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, isStarred: false, folderId: 410, folderUid: 'uid', @@ -115,7 +144,7 @@ describe('ManageDashboards', () => { title: 'Dashboard Test', url: 'dashboard/db/dashboard-test', icon: 'fa fa-folder', - tags: [], + tags, folderId: 499, isStarred: false, }, @@ -245,7 +274,7 @@ describe('ManageDashboards', () => { }); describe('when selecting dashboards', () => { - let ctrl; + let ctrl: ManageDashboardsCtrl; beforeEach(() => { ctrl = createCtrlWithStubs([]); @@ -254,16 +283,16 @@ describe('ManageDashboards', () => { describe('and no dashboards are selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, items: [{ id: 2, checked: false }], checked: false, - }, - { + }), + mockSection({ id: 0, items: [{ id: 3, checked: false }], checked: false, - }, + }), ]; ctrl.selectionChanged(); }); @@ -302,16 +331,16 @@ describe('ManageDashboards', () => { describe('and all folders and dashboards are selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, items: [{ id: 2, checked: true }], checked: true, - }, - { + }), + mockSection({ id: 0, items: [{ id: 3, checked: true }], checked: true, - }, + }), ]; ctrl.selectionChanged(); }); @@ -350,18 +379,18 @@ describe('ManageDashboards', () => { describe('and one dashboard in root is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: false }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true }], checked: false, - }, + }), ]; ctrl.selectionChanged(); }); @@ -378,18 +407,18 @@ describe('ManageDashboards', () => { describe('and one child dashboard is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: true }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: false }], checked: false, - }, + }), ]; ctrl.selectionChanged(); @@ -407,18 +436,18 @@ describe('ManageDashboards', () => { describe('and one child dashboard and one dashboard is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: true }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true }], checked: false, - }, + }), ]; ctrl.selectionChanged(); @@ -436,24 +465,24 @@ describe('ManageDashboards', () => { describe('and one child dashboard and one folder is selected', () => { beforeEach(() => { ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: false }], checked: true, - }, - { + }), + mockSection({ id: 3, title: 'folder', items: [{ id: 4, checked: true }], checked: false, - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: false }], checked: false, - }, + }), ]; ctrl.selectionChanged(); @@ -470,55 +499,55 @@ describe('ManageDashboards', () => { }); describe('when deleting dashboards', () => { - let toBeDeleted: any; + let toBeDeleted: FoldersAndDashboardUids; beforeEach(() => { ctrl = createCtrlWithStubs([]); ctrl.sections = [ - { + mockSection({ id: 1, uid: 'folder', title: 'folder', items: [{ id: 2, checked: true, uid: 'folder-dash' }], checked: true, - }, - { + }), + mockSection({ id: 3, title: 'folder-2', items: [{ id: 3, checked: true, uid: 'folder-2-dash' }], checked: false, uid: 'folder-2', - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true, uid: 'root-dash' }], checked: true, - }, + }), ]; toBeDeleted = ctrl.getFoldersAndDashboardsToDelete(); }); it('should return 1 folder', () => { - expect(toBeDeleted.folders.length).toEqual(1); + expect(toBeDeleted.folderUids.length).toEqual(1); }); it('should return 2 dashboards', () => { - expect(toBeDeleted.dashboards.length).toEqual(2); + expect(toBeDeleted.dashboardUids.length).toEqual(2); }); it('should filter out children if parent is checked', () => { - expect(toBeDeleted.folders[0]).toEqual('folder'); + expect(toBeDeleted.folderUids[0]).toEqual('folder'); }); it('should not filter out children if parent not is checked', () => { - expect(toBeDeleted.dashboards[0]).toEqual('folder-2-dash'); + expect(toBeDeleted.dashboardUids[0]).toEqual('folder-2-dash'); }); it('should not filter out children if parent is checked and root', () => { - expect(toBeDeleted.dashboards[1]).toEqual('root-dash'); + expect(toBeDeleted.dashboardUids[1]).toEqual('root-dash'); }); }); @@ -527,19 +556,19 @@ describe('ManageDashboards', () => { ctrl = createCtrlWithStubs([]); ctrl.sections = [ - { + mockSection({ id: 1, title: 'folder', items: [{ id: 2, checked: true, uid: 'dash' }], checked: false, uid: 'folder', - }, - { + }), + mockSection({ id: 0, title: 'General', items: [{ id: 3, checked: true, uid: 'dash-2' }], checked: false, - }, + }), ]; }); @@ -562,5 +591,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) { }, }; - return new ManageDashboardsCtrl({}, { getNav: () => {} }, searchSrvStub as SearchSrv, { isEditor: true }); + return new ManageDashboardsCtrl( + {} as BackendSrv, + { getNav: () => {} } as NavModelSrv, + searchSrvStub as SearchSrv, + { isEditor: true } as ContextSrv + ); } diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 31e5a392050..fdc63b931f7 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -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) }]; } /** diff --git a/public/app/core/utils/query.test.ts b/public/app/core/utils/query.test.ts new file mode 100644 index 00000000000..a69162751a4 --- /dev/null +++ b/public/app/core/utils/query.test.ts @@ -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'); + }); +}); diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts new file mode 100644 index 00000000000..933a73138a8 --- /dev/null +++ b/public/app/core/utils/query.ts @@ -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; + }); + }); +}; diff --git a/public/app/core/utils/validate.ts b/public/app/core/utils/validate.ts deleted file mode 100644 index c6663882808..00000000000 --- a/public/app/core/utils/validate.ts +++ /dev/null @@ -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]; -}; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index ac1b5f08632..1562953fd1b 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -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']); } diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 6cf14f81c86..4150cb9a848 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -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> = []; + 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']); diff --git a/public/app/features/dashboard/panel_editor/QueryOptions.tsx b/public/app/features/dashboard/panel_editor/QueryOptions.tsx index 0d031cb12ba..377582d7ce5 100644 --- a/public/app/features/dashboard/panel_editor/QueryOptions.tsx +++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx @@ -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]: [ diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index 686a8ba6d28..82af0804029 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -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); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 128bd8d0785..62a630c0408 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -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) { 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; diff --git a/public/app/features/explore/LogLabelStats.tsx b/public/app/features/explore/LogLabelStats.tsx index d689b6e70b2..466cc050e43 100644 --- a/public/app/features/explore/LogLabelStats.tsx +++ b/public/app/features/explore/LogLabelStats.tsx @@ -11,7 +11,9 @@ function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) { return (
-
{value}
+
+ {value} +
{count}
{percent}
diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx index 4946a6a505d..bbf338df8f9 100644 --- a/public/app/features/explore/Table.tsx +++ b/public/app/features/explore/Table.tsx @@ -40,11 +40,15 @@ export default class Table extends PureComponent { const tableModel = data || EMPTY_TABLE; const columnNames = tableModel.columns.map(({ text }) => text); const columns = tableModel.columns.map(({ filterable, text }) => ({ - Header: text, + Header: () => {text}, accessor: text, className: VALUE_REGEX.test(text) ? 'text-right' : '', show: text !== 'Time', - Cell: row => {row.value}, + Cell: row => ( + + {row.value} + + ), })); const noDataText = data ? 'The queries returned no data for a table.' : ''; diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index e0b84320fa7..fda8fe5eef4 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -60,7 +60,6 @@ import { splitCloseAction, splitOpenAction, addQueryRowAction, - AddQueryRowPayload, toggleGraphAction, toggleLogsAction, toggleTableAction, @@ -87,9 +86,12 @@ const updateExploreUIState = (exploreId, uiStateFragment: Partial { - const query = generateEmptyQuery(index + 1); - return addQueryRowAction({ exploreId, index, query }); +export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { + 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 { - 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 { - 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)); }; diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index a8815842c89..32bfe09a96b 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -127,7 +127,7 @@ export const itemReducer = reducerFactory({} 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({} 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({} 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 diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 9a156652a65..ab9d9aba08a 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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, diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index 708b472ec2e..8b612e6869e 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -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 {} - -export class BarGaugePanel extends PureComponent { - 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> { + renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { + const { options } = this.props; return ( ); - } + }; + + 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 ( - - {({ vizHeight, vizWidth, value }) => this.renderBarGauge(value, vizWidth, vizHeight)} - + ); } } diff --git a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx index 87e5defd277..3404c8d8805 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx @@ -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> { onThresholdsChanged = (thresholds: Threshold[]) => diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx index e7f2e2d7738..3c46adeb4f9 100644 --- a/public/app/plugins/panel/bargauge/module.tsx +++ b/public/app/plugins/panel/bargauge/module.tsx @@ -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(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); diff --git a/public/app/plugins/panel/bargauge/types.ts b/public/app/plugins/panel/bargauge/types.ts index 6c45b535b36..4a22a64bdd4 100644 --- a/public/app/plugins/panel/bargauge/types.ts +++ b/public/app/plugins/panel/bargauge/types.ts @@ -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', diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index f309b38e67d..b83dc9ad440 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -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 {} - -export class GaugePanel extends PureComponent { - 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> { + renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { + const { options } = this.props; return ( { 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 ( - - {({ vizHeight, vizWidth, value }) => this.renderGauge(value, vizWidth, vizHeight)} - + ); } } diff --git a/public/app/plugins/panel/gauge/GaugePanelEditor.tsx b/public/app/plugins/panel/gauge/GaugePanelEditor.tsx index f33ded6a28d..e3b21302331 100644 --- a/public/app/plugins/panel/gauge/GaugePanelEditor.tsx +++ b/public/app/plugins/panel/gauge/GaugePanelEditor.tsx @@ -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> { onThresholdsChanged = (thresholds: Threshold[]) => diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index b8e90a27bff..340af06a080 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -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(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); diff --git a/public/app/plugins/panel/gauge/types.ts b/public/app/plugins/panel/gauge/types.ts index 6922538c210..bab29d5d2ad 100644 --- a/public/app/plugins/panel/gauge/types.ts +++ b/public/app/plugins/panel/gauge/types.ts @@ -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, }; diff --git a/public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx b/public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx new file mode 100644 index 00000000000..d42c033eac2 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx @@ -0,0 +1,48 @@ +import React, { PureComponent } from 'react'; +import { VizOrientation } from '@grafana/ui'; +import { VizRepeater } from '@grafana/ui'; + +export interface Props { + 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 { + 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 extends PureComponent, State> { + constructor(props: Props) { + super(props); + this.state = { + values: props.getProcessedValues(), + }; + } + + componentDidUpdate(prevProps: Props) { + 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 ( + + {({ vizHeight, vizWidth, value }) => renderValue(value, vizWidth, vizHeight)} + + ); + } +} diff --git a/public/app/plugins/panel/singlestat2/README.md b/public/app/plugins/panel/singlestat2/README.md new file mode 100644 index 00000000000..42d72825c27 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/README.md @@ -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/) \ No newline at end of file diff --git a/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx b/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx new file mode 100644 index 00000000000..61b8588adce --- /dev/null +++ b/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx @@ -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> { + 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 ( + <> + + + + + + + + ); + } +} diff --git a/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx new file mode 100644 index 00000000000..1c731e0a0c7 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx @@ -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): 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> { + 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 ( +
+
{value.text}
+
+ ); + }; + + getProcessedValues = (): DisplayValue[] => { + return getSingleStatValues(this.props); + }; + + render() { + const { height, width, options, panelData } = this.props; + const { orientation } = options; + return ( + + ); + } +} diff --git a/public/app/plugins/panel/gauge/SingleStatValueEditor.tsx b/public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx similarity index 100% rename from public/app/plugins/panel/gauge/SingleStatValueEditor.tsx rename to public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx diff --git a/public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg b/public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg new file mode 100644 index 00000000000..746687d360f --- /dev/null +++ b/public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/singlestat2/module.tsx b/public/app/plugins/panel/singlestat2/module.tsx new file mode 100644 index 00000000000..283b32802e1 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/module.tsx @@ -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(SingleStatPanel); + +const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings']; + +export const singleStatBaseOptionsCheck = ( + options: Partial, + 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); diff --git a/public/app/plugins/panel/singlestat2/plugin.json b/public/app/plugins/panel/singlestat2/plugin.json new file mode 100644 index 00000000000..6828399ec2b --- /dev/null +++ b/public/app/plugins/panel/singlestat2/plugin.json @@ -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" + } + } +} diff --git a/public/app/plugins/panel/singlestat2/types.ts b/public/app/plugins/panel/singlestat2/types.ts new file mode 100644 index 00000000000..1f31783e814 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/types.ts @@ -0,0 +1,33 @@ +import { VizOrientation, ValueMapping, Threshold } from '@grafana/ui'; + +export interface SingleStatBaseOptions { + valueMappings: ValueMapping[]; + thresholds: Threshold[]; + valueOptions: SingleStatValueOptions; + orientation: VizOrientation; +} + +export interface SingleStatValueOptions { + unit: string; + suffix: string; + stat: string; + prefix: string; + decimals?: number | null; +} + +export interface SingleStatOptions extends SingleStatBaseOptions { + // TODO, fill in with options from angular +} + +export const defaults: SingleStatOptions = { + valueOptions: { + prefix: '', + suffix: '', + decimals: null, + stat: 'avg', + unit: 'none', + }, + valueMappings: [], + thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }], + orientation: VizOrientation.Auto, +}; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index eefba746c61..3bf76aeb3c3 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -12,6 +12,5 @@ export * from './plugins'; export * from './organization'; export * from './appNotifications'; export * from './search'; -export * from './form'; export * from './explore'; export * from './store'; diff --git a/public/sass/components/_panel_logs.scss b/public/sass/components/_panel_logs.scss index 367d25ada6b..22c82461e85 100644 --- a/public/sass/components/_panel_logs.scss +++ b/public/sass/components/_panel_logs.scss @@ -299,6 +299,8 @@ $column-horizontal-spacing: 10px; &__value { flex: 1; + text-overflow: ellipsis; + overflow: hidden; } &__count, diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index 58403ac7ed7..f9124773c97 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -3,6 +3,7 @@ import config from 'app/core/config'; import * as dateMath from 'app/core/utils/datemath'; import { angularMocks, sinon } from '../lib/common'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { PanelPlugin } from 'app/types'; export function ControllerTestContext(this: any) { const self = this; @@ -62,7 +63,7 @@ export function ControllerTestContext(this: any) { $rootScope.colors.push('#' + i); } - config.panels['test'] = { info: {} }; + config.panels['test'] = { info: {} } as PanelPlugin; self.ctrl = $controller( Ctrl, { $scope: self.scope }, diff --git a/scripts/circle-test-cache-servers.sh b/scripts/circle-test-cache-servers.sh new file mode 100755 index 00000000000..bacd9928362 --- /dev/null +++ b/scripts/circle-test-cache-servers.sh @@ -0,0 +1,16 @@ +#!/bin/bash +function exit_if_fail { + command=$@ + echo "Executing '$command'" + eval $command + rc=$? + if [ $rc -ne 0 ]; then + echo "'$command' returned $rc." + exit $rc + fi +} + +echo "running redis and memcache tests" + +time exit_if_fail go test -tags=redis ./pkg/infra/remotecache/... +time exit_if_fail go test -tags=memcached ./pkg/infra/remotecache/... diff --git a/scripts/cli/utils/useSpinner.ts b/scripts/cli/utils/useSpinner.ts index 81ed9bb6fcf..298a6516689 100644 --- a/scripts/cli/utils/useSpinner.ts +++ b/scripts/cli/utils/useSpinner.ts @@ -10,8 +10,7 @@ export const useSpinner = (spinnerLabel: string, fn: FnToSpin, killProcess await fn(options); spinner.succeed(); } catch (e) { - spinner.fail(); - console.log(e); + spinner.fail(e); if (killProcess) { process.exit(1); } diff --git a/scripts/grunt/default_task.js b/scripts/grunt/default_task.js index 8a71ea26627..a656e0c60af 100644 --- a/scripts/grunt/default_task.js +++ b/scripts/grunt/default_task.js @@ -34,14 +34,17 @@ module.exports = function (grunt) { ]); grunt.registerTask('no-only-tests', function () { - var files = grunt.file.expand('public/**/*_specs\.ts', 'public/**/*_specs\.js'); + var files = grunt.file.expand( + 'public/**/*@(_specs|\.test)\.@(ts|js|tsx|jsx)', + 'packages/grafana-ui/**/*@(_specs|\.test)\.@(ts|js|tsx|jsx)' + ); files.forEach(function (spec) { var rows = grunt.file.read(spec).split('\n'); rows.forEach(function (row) { if (row.indexOf('.only(') > 0) { grunt.log.errorlns(row); - grunt.fail.warn('found only statement in test: ' + spec) + grunt.fail.warn('found only statement in test: ' + spec); } }); }); diff --git a/style_guides/backend.md b/style_guides/backend.md new file mode 100644 index 00000000000..1c6c86efc0b --- /dev/null +++ b/style_guides/backend.md @@ -0,0 +1,30 @@ +# Backend style guide + +Grafanas backend has been developed for a long time with a mix of code styles. + +This style guide is a guide for how we want to write Go code in the future. Generally, we want to follow the style guides used in Go [Code Review Comments](https://code.google.com/p/go-wiki/wiki/CodeReviewComments) and Peter Bourgon's [Go: Best Practices for Production Environments](http://peter.bourgon.org/go-in-production/#formatting-and-style) + + +## Global state +Global state makes testing and debugging software harder and its something we want to avoid when possible. +Unfortunately, there is quite a lot of global state in Grafana. The way we want to migrate away from this +is to use the `inject` package to wire up all dependencies either in `pkg/cmd/grafana-server/main.go` or +self registering using `registry.RegisterService` ex https://github.com/grafana/grafana/blob/master/pkg/services/cleanup/cleanup.go#L25 + +### the `bus` +`bus.Dispatch` is used in many places and something we want to avoid in the future since it refers to a global instance. +The preferred solution, in this case, is to inject the `bus` into services or take the bus instance as a parameter into functions. + +### settings package +In the `setting` packages there are many global variables which Grafana sets at startup. This is also something we want to move +away from and move as much configuration as possible to the `setting.Cfg` struct and pass it around, just like the bus. + +## Linting and formatting +We enforce strict `gofmt` formating and use some linters on our codebase. You can find the current list of linters at https://github.com/grafana/grafana/blob/master/scripts/gometalinter.sh#L23 + +We don't enforce `golint` but we encourage it and we will test so the number of linting errors does not increase over time. + +## Testing +We use GoConvey for BDD/scenario based testing. Which we think is useful for testing certain chain or interactions. Ex https://github.com/grafana/grafana/blob/master/pkg/services/auth/auth_token_test.go + +For smaller tests its preferred to use standard library testing. diff --git a/style_guides/frontend.md b/style_guides/frontend.md index caef4f711ef..18069183e66 100644 --- a/style_guides/frontend.md +++ b/style_guides/frontend.md @@ -1,36 +1,36 @@ # Frontend Style Guide -Generally we follow the Airbnb [React Style Guide](https://github.com/airbnb/javascript/tree/master/react). +Generally we follow the Airbnb [React Style Guide](https://github.com/airbnb/javascript/tree/master/react). ## Table of Contents - 1. [Basic Rules](#basic-rules) - 1. [File & Component Organization](#Organization) - 1. [Naming](#naming) - 1. [Declaration](#declaration) - 1. [Props](#props) - 1. [Refs](#refs) - 1. [Methods](#methods) - 1. [Ordering](#ordering) +1. [Basic Rules](#basic-rules) +1. [File & Component Organization](#Organization) +1. [Naming](#naming) +1. [Declaration](#declaration) +1. [Props](#props) +1. [Refs](#refs) +1. [Methods](#methods) +1. [Ordering](#ordering) ## Basic rules -* Try to keep files small and focused and break large components up into sub components. +- Try to keep files small and focused and break large components up into sub components. ## Organization -* Components and types that needs to be used by external plugins needs to go into @grafana/ui -* Components should get their own folder under features/xxx/components - * Sub components can live in that component folders, so small component do not need their own folder - * Place test next to their component file (same dir) - * Component sass should live in the same folder as component code -* State logic & domain models should live in features/xxx/state -* Containers (pages) can live in feature root features/xxx - * up for debate? +- Components and types that needs to be used by external plugins needs to go into @grafana/ui +- Components should get their own folder under features/xxx/components + - Sub components can live in that component folders, so small component do not need their own folder + - Place test next to their component file (same dir) + - Component sass should live in the same folder as component code +- State logic & domain models should live in features/xxx/state +- Containers (pages) can live in feature root features/xxx + - up for debate? ## Props -* Name callback props & handlers with a "on" prefix. +- Name callback props & handlers with a "on" prefix. ```tsx // good @@ -56,5 +56,32 @@ render() { } ``` +- React Component definitions +```jsx +// good +export class YourClass extends PureComponent<{},{}> { ... } +// bad +export class YourClass extends PureComponent { ... } +``` + +- React Component constructor + +```typescript +// good +constructor(props:Props) {...} + +// bad +constructor(props) {...} +``` + +- React Component defaultProps + +```typescript +// good +static defaultProps: Partial = { ... } + +// bad +static defaultProps = { ... } +``` diff --git a/style_guides/pull-request-review-checklist.md b/style_guides/pull-request-review-checklist.md new file mode 100644 index 00000000000..2fd017386ea --- /dev/null +++ b/style_guides/pull-request-review-checklist.md @@ -0,0 +1,36 @@ +# Pull Request Review Checklist + +## High level checks + +- [ ] The pull request adds value and the impact of the change is in line with [Frontend Style Guide](https://github.com/grafana/grafana/blob/master/style_guides/frontend.md). +- [ ] The pull request works the way it says it should do. +- [ ] The pull request does not increase the Angular code base. + > We are in the process of migrating to React so any increment of Angular code is generally discouraged from. (there are a few exceptions) +- [ ] The pull request closes one issue if possible and does not fix unrelated issues within the same pull request. +- [ ] The pull request contains necessary tests. + +## Low level checks + +- [ ] The pull request contains a title that explains the PR. +- [ ] The pull request contains necessary link(s) to issue(s). +- [ ] The pull request contains commits with commit messages that are small and understandable. +- [ ] The pull request does not contain magic strings or numbers that could be replaced with an `Enum` or `const` instead. + +### Bug specific checks + +- [ ] The pull request contains only one commit if possible. +- [ ] The pull request contains `closes: #Issue` or `fixes: #Issue` in pull request description. + +## Frontend specific checks + +- [ ] The pull request does not increase the number of `implicit any` errors. +- [ ] The pull request does not contain uses of `any` or `{}` without comments describing why. +- [ ] The pull request does not contain large React component that could easily be split into several smaller components. +- [ ] The pull request does not contain back end calls directly from components, use actions and Redux instead. + +### Redux specific checks (skip if pull request does not contain Redux changes) + +- [ ] The pull request does not contain code that mutate state in reducers or thunks. +- [ ] The pull request uses helpers `actionCreatorFactory` and `reducerFactory` instead of traditional `switch statement` reducers in Redux. ([Redux framework](https://github.com/grafana/grafana/blob/master/style_guides/redux.md)) +- [ ] The pull request uses `reducerTester` to test reducers.([Redux framework](https://github.com/grafana/grafana/blob/master/style_guides/redux.md)) +- [ ] The pull request does not contain code that access reducers state slice directly, instead the code uses state selectors to access state. diff --git a/style_guides/redux.md b/style_guides/redux.md new file mode 100644 index 00000000000..ff64fe400f3 --- /dev/null +++ b/style_guides/redux.md @@ -0,0 +1,158 @@ +# Redux framework + +To reduce the amount of boilerplate code used to create a strongly typed redux solution with actions, action creators, reducers and tests we've introduced a small framework around Redux. + +`+` Much less boilerplate code +`-` Non Redux standard api + +## New core functionality + +### actionCreatorFactory + +Used to create an action creator with the following signature + +```typescript +{ type: string , (payload: T): {type: string; payload: T;} } +``` + +where the `type` string will be ensured to be unique and `T` is the type supplied to the factory. + +#### Example + +```typescript +export const someAction = actionCreatorFactory('SOME_ACTION').create(); + +// later when dispatched +someAction('this rocks!'); +``` + +```typescript +// best practices, always use an interface as type +interface SomeAction { + data: string; +} +export const someAction = actionCreatorFactory('SOME_ACTION').create(); + +// later when dispatched +someAction({ data: 'best practices' }); +``` + +```typescript +// declaring an action creator with a type string that has already been defined will throw +export const someAction = actionCreatorFactory('SOME_ACTION').create(); +export const theAction = actionCreatorFactory('SOME_ACTION').create(); // will throw +``` + +### noPayloadActionCreatorFactory + +Used when you don't need to supply a payload for your action. Will create an action creator with the following signature + +```typescript +{ type: string , (): {type: string; payload: undefined;} } +``` + +where the `type` string will be ensured to be unique. + +#### Example + +```typescript +export const noPayloadAction = noPayloadActionCreatorFactory('NO_PAYLOAD').create(); + +// later when dispatched +noPayloadAction(); +``` + +```typescript +// declaring an action creator with a type string that has already been defined will throw +export const noPayloadAction = noPayloadActionCreatorFactory('NO_PAYLOAD').create(); +export const noAction = noPayloadActionCreatorFactory('NO_PAYLOAD').create(); // will throw +``` + +### reducerFactory + +Fluent API used to create a reducer. (same as implementing the standard switch statement in Redux) + +#### Example + +```typescript +interface ExampleReducerState { + data: string[]; +} + +const intialState: ExampleReducerState = { data: [] }; + +export const someAction = actionCreatorFactory('SOME_ACTION').create(); +export const otherAction = actionCreatorFactory('Other_ACTION').create(); + +export const exampleReducer = reducerFactory(intialState) + // addMapper is the function that ties an action creator to a state change + .addMapper({ + // action creator to filter out which mapper to use + filter: someAction, + // mapper function where the state change occurs + mapper: (state, action) => ({ ...state, data: state.data.concat(action.payload) }), + }) + // a developer can just chain addMapper functions until reducer is done + .addMapper({ + filter: otherAction, + mapper: (state, action) => ({ ...state, data: action.payload }), + }) + .create(); // this will return the reducer +``` + +#### Typing limitations + +There is a challenge left with the mapper function that I can not solve with TypeScript. The signature of a mapper is + +```typescript +(state: State, action: ActionOf) => State; +``` + +If you would to return an object that is not of the state type like the following mapper + +```typescript +mapper: (state, action) => ({ nonExistingProperty: ''}), +``` + +Then you would receive the following compile error + +```shell +[ts] Property 'data' is missing in type '{ nonExistingProperty: string; }' but required in type 'ExampleReducerState'. [2741] +``` + +But if you return an object that is spreading state and add a non existing property type like the following mapper + +```typescript +mapper: (state, action) => ({ ...state, nonExistingProperty: ''}), +``` + +Then you would not receive any compile error. + +If you want to make sure that never happens you can just supply the State type to the mapper callback like the following mapper: + +```typescript +mapper: (state, action): ExampleReducerState => ({ ...state, nonExistingProperty: 'kalle' }), +``` + +Then you would receive the following compile error + +```shell +[ts] +Type '{ nonExistingProperty: string; data: string[]; }' is not assignable to type 'ExampleReducerState'. + Object literal may only specify known properties, and 'nonExistingProperty' does not exist in type 'ExampleReducerState'. [2322] +``` + +## New test functionality + +### reducerTester + +Fluent API that simplifies the testing of reducers + +#### Example + +```typescript +reducerTester() + .givenReducer(someReducer, initialState) + .whenActionIsDispatched(someAction('reducer tests')) + .thenStateShouldEqual({ ...initialState, data: 'reducer tests' }); +```