From f13c267f1c6b1d5dce33abb112395e5afc91694e Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Tue, 27 Oct 2020 11:18:37 +0100 Subject: [PATCH 001/132] PanelEdit: Prevent the preview pane to be resized further than window height (#28370) * use percentage on topPanel, limit resize * add relative size to rightPanel, resize split panes on resize * lock min size of options pane to min 300px, adding debounce * sorting imports * assigning the ref, remove debounce * set default uistate to number instead of string * revert go.sum and go.mod * fix go.mod --- .../components/PanelEditor/PanelEditor.tsx | 110 ++++++++++++------ .../components/PanelEditor/state/reducers.ts | 4 +- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 0a1b02d37a1..ad9fc90b77e 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -1,38 +1,41 @@ -import React, { PureComponent } from 'react'; -import { FieldConfigSource, GrafanaTheme, PanelPlugin } from '@grafana/data'; -import { Button, HorizontalGroup, Icon, RadioButtonGroup, stylesFactory } from '@grafana/ui'; -import { css, cx } from 'emotion'; -import config from 'app/core/config'; -import AutoSizer from 'react-virtualized-auto-sizer'; - -import { PanelModel } from '../../state/PanelModel'; -import { DashboardModel } from '../../state/DashboardModel'; -import { DashboardPanel } from '../../dashgrid/DashboardPanel'; - -import SplitPane from 'react-split-pane'; -import { StoreState } from '../../../../types/store'; +import React, { createRef, MutableRefObject, PureComponent } from 'react'; import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; -import { updateLocation } from '../../../../core/reducers/location'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import SplitPane from 'react-split-pane'; +import { css, cx } from 'emotion'; import { Unsubscribable } from 'rxjs'; -import { DisplayMode, displayModes, PanelEditorTab } from './types'; + +import { FieldConfigSource, GrafanaTheme, PanelPlugin } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Button, HorizontalGroup, Icon, RadioButtonGroup, stylesFactory } from '@grafana/ui'; + +import config from 'app/core/config'; +import { appEvents } from 'app/core/core'; +import { calculatePanelSize } from './utils'; + import { PanelEditorTabs } from './PanelEditorTabs'; import { DashNavTimeControls } from '../DashNav/DashNavTimeControls'; -import { CoreEvents, LocationState } from 'app/types'; -import { calculatePanelSize } from './utils'; -import { initPanelEditor, panelEditorCleanUp, updatePanelEditorUIState } from './state/actions'; -import { PanelEditorUIState, setDiscardChanges } from './state/reducers'; -import { getPanelEditorTabs } from './state/selectors'; -import { getPanelStateById } from '../../state/selectors'; import { OptionsPaneContent } from './OptionsPaneContent'; -import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton'; -import { VariableModel } from 'app/features/variables/types'; -import { getVariables } from 'app/features/variables/state/selectors'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { BackButton } from 'app/core/components/BackButton/BackButton'; -import { appEvents } from 'app/core/core'; import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy'; -import { selectors } from '@grafana/e2e-selectors'; +import { DashboardPanel } from '../../dashgrid/DashboardPanel'; + +import { initPanelEditor, panelEditorCleanUp, updatePanelEditorUIState } from './state/actions'; + +import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; +import { updateLocation } from 'app/core/reducers/location'; +import { PanelEditorUIState, setDiscardChanges } from './state/reducers'; + +import { getPanelEditorTabs } from './state/selectors'; +import { getPanelStateById } from '../../state/selectors'; +import { getVariables } from 'app/features/variables/state/selectors'; + +import { CoreEvents, LocationState, StoreState } from 'app/types'; +import { DisplayMode, displayModes, PanelEditorTab } from './types'; +import { VariableModel } from 'app/features/variables/types'; +import { DashboardModel, PanelModel } from '../../state'; interface OwnProps { dashboard: DashboardModel; @@ -62,15 +65,28 @@ type Props = OwnProps & ConnectedProps & DispatchProps; export class PanelEditorUnconnected extends PureComponent { querySubscription: Unsubscribable; + rafToken = createRef(); componentDidMount() { this.props.initPanelEditor(this.props.sourcePanel, this.props.dashboard); + + window.addEventListener('resize', this.updateSplitPaneSize); } componentWillUnmount() { this.props.panelEditorCleanUp(); + window.removeEventListener('resize', this.updateSplitPaneSize); } + updateSplitPaneSize = () => { + if (this.rafToken.current !== undefined) { + window.cancelAnimationFrame(this.rafToken.current!); + } + (this.rafToken as MutableRefObject).current = window.requestAnimationFrame(() => { + this.forceUpdate(); + }); + }; + onPanelExit = () => { this.props.updateLocation({ query: { editPanel: null, tab: null }, @@ -130,11 +146,16 @@ export class PanelEditorUnconnected extends PureComponent { return; } - const targetPane = pane === Pane.Top ? 'topPaneSize' : 'rightPaneSize'; const { updatePanelEditorUIState } = this.props; - updatePanelEditorUIState({ - [targetPane]: size, - }); + if (pane === Pane.Top) { + updatePanelEditorUIState({ + topPaneSize: size / window.innerHeight, + }); + } else { + updatePanelEditorUIState({ + rightPaneSize: size / window.innerWidth, + }); + } }; onDragStarted = () => { @@ -186,11 +207,21 @@ export class PanelEditorUnconnected extends PureComponent { renderHorizontalSplit(styles: EditorStyles) { const { dashboard, panel, tabs, uiState } = this.props; + /* + Guesstimate the height of the browser window minus + panel toolbar and editor toolbar (~120px). This is to prevent resizing + the preview window beyond the browser window. + */ + const windowHeight = window.innerHeight - 120; + const size = uiState.topPaneSize >= 1 ? uiState.topPaneSize : (uiState.topPaneSize as number) * window.innerHeight; + return tabs.length > 0 ? ( { ); } - renderOptionsPane() { - const { plugin, dashboard, panel, uiState } = this.props; + renderOptionsPane(width: number) { + const { plugin, dashboard, panel } = this.props; if (!plugin) { return
; @@ -300,7 +331,7 @@ export class PanelEditorUnconnected extends PureComponent { plugin={plugin} dashboard={dashboard} panel={panel} - width={uiState.rightPaneSize as number} + width={width} onClose={this.onTogglePanelOptions} onFieldConfigsChange={this.onFieldConfigChange} onPanelOptionsChanged={this.onPanelOptionsChanged} @@ -312,10 +343,21 @@ export class PanelEditorUnconnected extends PureComponent { renderWithOptionsPane(styles: EditorStyles) { const { uiState } = this.props; + // Limit options pane width to 90% of screen. + const maxWidth = window.innerWidth * 0.9; + + // Need to handle when width is relative. ie a percentage of the viewport + const width = + uiState.rightPaneSize <= 1 + ? (uiState.rightPaneSize as number) * window.innerWidth + : (uiState.rightPaneSize as number); + return ( = 300 ? width : 300} primary="second" /* Use persisted state for default size */ defaultSize={uiState.rightPaneSize} @@ -324,7 +366,7 @@ export class PanelEditorUnconnected extends PureComponent { onDragFinished={size => this.onDragFinished(Pane.Right, size)} > {this.renderHorizontalSplit(styles)} - {this.renderOptionsPane()} + {this.renderOptionsPane(width)} ); } diff --git a/public/app/features/dashboard/components/PanelEditor/state/reducers.ts b/public/app/features/dashboard/components/PanelEditor/state/reducers.ts index 9503099a219..68de6746aa0 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/reducers.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/reducers.ts @@ -9,7 +9,7 @@ export const PANEL_EDITOR_UI_STATE_STORAGE_KEY = 'grafana.dashboard.editor.ui'; export const DEFAULT_PANEL_EDITOR_UI_STATE: PanelEditorUIState = { isPanelOptionsVisible: true, rightPaneSize: 400, - topPaneSize: '45%', + topPaneSize: 0.45, mode: DisplayMode.Fill, }; @@ -25,7 +25,7 @@ export interface PanelEditorUIState { } export interface PanelEditorState { - /* These are functions as they are mutaded later on and redux toolkit will Object.freeze state so + /* These are functions as they are mutated later on and redux toolkit will Object.freeze state so * we need to store these using functions instead */ getSourcePanel: () => PanelModel; getPanel: () => PanelModel; From d1ed163fc6f0a877bdc5b0412bd6d2f52348ee29 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 27 Oct 2020 11:35:59 +0100 Subject: [PATCH 002/132] Use fetch API in InfluxDB data source (#28555) * Use fetch API in InfluxDB data source * Review comments --- .../plugins/datasource/influxdb/datasource.ts | 189 ++++++++++-------- .../influxdb/specs/datasource.test.ts | 47 +++-- 2 files changed, 131 insertions(+), 105 deletions(-) diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 6215c1610ac..62648e24c7b 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -12,6 +12,7 @@ import { MetricFindValue, AnnotationQueryRequest, AnnotationEvent, + DataQueryError, } from '@grafana/data'; import { v4 as uuidv4 } from 'uuid'; import InfluxSeries from './influx_series'; @@ -21,8 +22,9 @@ import { InfluxQueryBuilder } from './query_builder'; import { InfluxQuery, InfluxOptions, InfluxVersion } from './types'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getBackendSrv, DataSourceWithBackend, frameToMetricFindValue } from '@grafana/runtime'; -import { Observable, from } from 'rxjs'; +import { Observable, throwError, of } from 'rxjs'; import { FluxQueryEditor } from './components/FluxQueryEditor'; +import { catchError, map } from 'rxjs/operators'; export default class InfluxDatasource extends DataSourceWithBackend { type: string; @@ -75,7 +77,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { + classicQuery(options: any): Observable { let timeFilter = this.getTimeFilter(options); const scopedVars = options.scopedVars; const targets = _.cloneDeep(options.targets); @@ -135,7 +137,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if (!data || !data.results) { - return []; - } - - const seriesList = []; - for (i = 0; i < data.results.length; i++) { - const result = data.results[i]; - if (!result || !result.series) { - continue; + return this._seriesQuery(allQueries, options).pipe( + map((data: any) => { + if (!data || !data.results) { + return { data: [] }; } - const target = queryTargets[i]; - let alias = target.alias; - if (alias) { - alias = this.templateSrv.replace(target.alias, options.scopedVars); - } - - const meta: QueryResultMeta = { - executedQueryString: data.executedQueryString, - }; - - const influxSeries = new InfluxSeries({ - refId: target.refId, - series: data.results[i].series, - alias: alias, - meta, - }); - - switch (target.resultFormat) { - case 'logs': - meta.preferredVisualisationType = 'logs'; - case 'table': { - seriesList.push(influxSeries.getTable()); - break; + const seriesList = []; + for (i = 0; i < data.results.length; i++) { + const result = data.results[i]; + if (!result || !result.series) { + continue; } - default: { - const timeSeries = influxSeries.getTimeSeries(); - for (y = 0; y < timeSeries.length; y++) { - seriesList.push(timeSeries[y]); + + const target = queryTargets[i]; + let alias = target.alias; + if (alias) { + alias = this.templateSrv.replace(target.alias, options.scopedVars); + } + + const meta: QueryResultMeta = { + executedQueryString: data.executedQueryString, + }; + + const influxSeries = new InfluxSeries({ + refId: target.refId, + series: data.results[i].series, + alias: alias, + meta, + }); + + switch (target.resultFormat) { + case 'logs': + meta.preferredVisualisationType = 'logs'; + case 'table': { + seriesList.push(influxSeries.getTable()); + break; + } + default: { + const timeSeries = influxSeries.getTimeSeries(); + for (y = 0; y < timeSeries.length; y++) { + seriesList.push(timeSeries[y]); + } + break; } - break; } } - } - return { data: seriesList }; - }); + return { data: seriesList }; + }) + ); } async annotationQuery(options: AnnotationQueryRequest): Promise { @@ -219,15 +223,17 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if (!data || !data.results || !data.results[0]) { - throw { message: 'No results in response from InfluxDB' }; - } - return new InfluxSeries({ - series: data.results[0].series, - annotation: options.annotation, - }).getAnnotations(); - }); + return this._seriesQuery(query, options) + .toPromise() + .then((data: any) => { + if (!data || !data.results || !data.results[0]) { + throw { message: 'No results in response from InfluxDB' }; + } + return new InfluxSeries({ + series: data.results[0].series, + annotation: options.annotation, + }).getAnnotations(); + }); } targetContainsTemplate(target: any) { @@ -268,14 +274,12 @@ export default class InfluxDatasource extends DataSourceWithBackend { - const expandedTag = { + expandedQuery.tags = query.tags.map(tag => { + return { ...tag, value: this.templateSrv.replace(tag.value, undefined, 'regex'), }; - return expandedTag; }); - expandedQuery.tags = expandedTags; } return expandedQuery; }); @@ -305,9 +309,11 @@ export default class InfluxDatasource extends DataSourceWithBackend { - return this.responseParser.parse(query, resp); - }); + return this._seriesQuery(interpolated, options) + .toPromise() + .then(resp => { + return this.responseParser.parse(query, resp); + }); } getTagKeys(options: any = {}) { @@ -324,7 +330,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { const error = _.get(res, 'results[0].error'); if (error) { @@ -459,14 +466,15 @@ export default class InfluxDatasource extends DataSourceWithBackend { + .fetch(req) + .pipe( + map((result: any) => { const { data } = result; if (data) { data.executedQueryString = q; if (data.results) { const errors = result.data.results.filter((elem: any) => elem.error); + if (errors.length > 0) { throw { message: 'InfluxDB Error: ' + errors[0].error, @@ -476,29 +484,42 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if ((Number.isInteger(err.status) && err.status !== 0) || err.status >= 300) { - if (err.data && err.data.error) { - throw { - message: 'InfluxDB Error: ' + err.data.error, - data: err.data, - config: err.config, - }; - } else { - throw { - message: 'Network Error: ' + err.statusText + '(' + err.status + ')', - data: err.data, - config: err.config, - }; - } - } else { - throw err; + }), + catchError(err => { + if (err.cancelled) { + return of(err); } - } + + return throwError(this.handleErrors(err)); + }) ); } + handleErrors(err: any) { + const error: DataQueryError = { + message: + (err && err.status) || + (err && err.message) || + 'Unknown error during query transaction. Please check JS console logs.', + }; + + if ((Number.isInteger(err.status) && err.status !== 0) || err.status >= 300) { + if (err.data && err.data.error) { + error.message = 'InfluxDB Error: ' + err.data.error; + error.data = err.data; + // @ts-ignore + error.config = err.config; + } else { + error.message = 'Network Error: ' + err.statusText + '(' + err.status + ')'; + error.data = err.data; + // @ts-ignore + error.config = err.config; + } + } + + return error; + } + getTimeFilter(options: any) { const from = this.getInfluxTime(options.rangeRaw.from, false, options.timezone); const until = this.getInfluxTime(options.rangeRaw.to, true, options.timezone); diff --git a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts index 2ae22a67eeb..d42c54b0421 100644 --- a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts +++ b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts @@ -2,6 +2,8 @@ import InfluxDatasource from '../datasource'; import { TemplateSrvStub } from 'test/specs/helpers'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { of } from 'rxjs'; +import { FetchResponse } from '@grafana/runtime'; //@ts-ignore const templateSrv = new TemplateSrvStub(); @@ -16,7 +18,7 @@ describe('InfluxDataSource', () => { instanceSettings: { url: 'url', name: 'influxDb', jsonData: { httpMode: 'GET' } }, }; - const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); beforeEach(() => { jest.clearAllMocks(); @@ -35,12 +37,13 @@ describe('InfluxDataSource', () => { let requestQuery: any, requestMethod: any, requestData: any, response: any; beforeEach(async () => { - datasourceRequestMock.mockImplementation((req: any) => { + fetchMock.mockImplementation((req: any) => { requestMethod = req.method; requestQuery = req.params.q; requestData = req.data; - return Promise.resolve({ + return of({ data: { + status: 'success', results: [ { series: [ @@ -53,7 +56,7 @@ describe('InfluxDataSource', () => { }, ], }, - }); + } as FetchResponse); }); response = await ctx.ds.metricFindQuery(query, queryOptions); @@ -96,8 +99,8 @@ describe('InfluxDataSource', () => { }; it('throws an error', async () => { - datasourceRequestMock.mockImplementation((req: any) => { - return Promise.resolve({ + fetchMock.mockImplementation((req: any) => { + return of({ data: { results: [ { @@ -105,7 +108,7 @@ describe('InfluxDataSource', () => { }, ], }, - }); + } as FetchResponse); }); try { @@ -132,23 +135,25 @@ describe('InfluxDataSource', () => { let requestMethod: any, requestQueryParameter: any, queryEncoded: any, requestQuery: any; beforeEach(async () => { - datasourceRequestMock.mockImplementation((req: any) => { + fetchMock.mockImplementation((req: any) => { requestMethod = req.method; requestQueryParameter = req.params; requestQuery = req.data; - return Promise.resolve({ - results: [ - { - series: [ - { - name: 'measurement', - columns: ['max'], - values: [[1]], - }, - ], - }, - ], - }); + return of({ + data: { + results: [ + { + series: [ + { + name: 'measurement', + columns: ['max'], + values: [[1]], + }, + ], + }, + ], + }, + } as FetchResponse); }); queryEncoded = await ctx.ds.serializeParams({ q: query }); From 4468d41417f8528464aa5bc39b18867a756da78e Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 27 Oct 2020 13:08:08 +0100 Subject: [PATCH 003/132] Plugin signing: UI information (#28469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * first pass * return list * types and cleanup * add to plugin page and add styles * update comment * update comment * fix component path * simplify error component * simplify error struct * fix tests * don't export and fix string() * update naming * remove frontend * introduce phantom loader * track single error * remove error from base * remove unused struct * remove unnecessary filter * add errors endpoint * Update set log to use id field Co-authored-by: Arve Knudsen * skip adding BE plugins * remove errs from plugin + ds list * remove unnecessary fields * add signature state to panels * Fetch plugins errors * grafana/ui component tweaks * DS Picker - add unsigned badge * VizPicker - add unsigned badge * PluginSignatureBadge tweaks * Plugins list - add signatures info box * New datasource page - add signatures info box * Plugin page - add signatures info box * Fix test * Do not show Core label in viz picker * Update public/app/features/plugins/PluginsErrorsInfo.tsx Co-authored-by: Torkel Ödegaard * Update public/app/features/plugins/PluginListPage.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/plugins/PluginListPage.tsx Co-authored-by: Alex Khomenko * Update public/app/features/datasources/NewDataSourcePage.tsx Co-authored-by: Alex Khomenko * Review comments 1 * Review comments 2 * Update public/app/features/plugins/PluginsErrorsInfo.tsx * Update public/app/features/plugins/PluginPage.tsx * Prettier fix * remove stale backend code * Docs issues fix Co-authored-by: Will Browne Co-authored-by: Will Browne Co-authored-by: Arve Knudsen Co-authored-by: Torkel Ödegaard Co-authored-by: Alex Khomenko --- packages/grafana-data/src/types/plugin.ts | 20 +++- .../src/selectors/pages.ts | 10 ++ .../grafana-ui/src/components/Alert/Alert.tsx | 23 ++-- .../grafana-ui/src/components/Badge/Badge.tsx | 12 +-- .../src/components/InfoBox/InfoBox.tsx | 23 ++-- .../grafana-ui/src/components/Select/types.ts | 2 +- packages/grafana-ui/src/utils/colors.ts | 20 ++++ public/app/core/components/Page/Page.tsx | 10 +- .../components/Select/DataSourcePicker.tsx | 15 ++- .../panel_editor/VizTypePickerPlugin.tsx | 5 + .../datasources/NewDataSourcePage.tsx | 12 +++ public/app/features/plugins/PluginList.tsx | 3 +- .../app/features/plugins/PluginListItem.tsx | 3 +- .../features/plugins/PluginListPage.test.tsx | 62 +++++++++-- .../app/features/plugins/PluginListPage.tsx | 76 +++++++------ public/app/features/plugins/PluginPage.tsx | 45 +++++++- .../features/plugins/PluginSignatureBadge.tsx | 46 ++++++-- .../features/plugins/PluginsErrorsInfo.tsx | 100 ++++++++++++++++++ .../__snapshots__/PluginList.test.tsx.snap | 1 + .../PluginListItem.test.tsx.snap | 2 + .../PluginListPage.test.tsx.snap | 66 ------------ public/app/features/plugins/state/actions.ts | 19 +++- .../features/plugins/state/reducers.test.ts | 1 + public/app/features/plugins/state/reducers.ts | 7 +- .../app/features/plugins/state/selectors.ts | 3 + public/app/types/plugins.ts | 3 +- 26 files changed, 426 insertions(+), 163 deletions(-) create mode 100644 public/app/features/plugins/PluginsErrorsInfo.tsx delete mode 100644 public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 0aa96f68ed8..6d0b5f56a37 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -2,12 +2,14 @@ import { ComponentClass } from 'react'; import { KeyValue } from './data'; import { LiveChannelSupport } from './live'; +/** Describes plugins life cycle status */ export enum PluginState { - alpha = 'alpha', // Only included it `enable_alpha` is true + alpha = 'alpha', // Only included if `enable_alpha` config option is true beta = 'beta', // Will show a warning banner deprecated = 'deprecated', // Will continue to work -- but not show up in the options to add } +/** Describes {@link https://grafana.com/docs/grafana/latest/plugins | type of plugin} */ export enum PluginType { panel = 'panel', datasource = 'datasource', @@ -15,12 +17,26 @@ export enum PluginType { renderer = 'renderer', } +/** Describes status of {@link https://grafana.com/docs/grafana/latest/plugins/plugin-signature-verification/ | plugin signature} */ export enum PluginSignatureStatus { internal = 'internal', // core plugin, no signature valid = 'valid', // signed and accurate MANIFEST invalid = 'invalid', // invalid signature modified = 'modified', // valid signature, but content mismatch - unsigned = 'unsigned', // no MANIFEST file + missing = 'missing', // missing signature file +} + +/** Describes error code returned from Grafana plugins API call */ +export enum PluginErrorCode { + missingSignature = 'signatureMissing', + invalidSignature = 'signatureInvalid', + modifiedSignature = 'signatureModified', +} + +/** Describes error returned from Grafana plugins API call */ +export interface PluginError { + errorCode: PluginErrorCode; + pluginId: string; } export interface PluginMeta { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 9d2558ecd46..4f7b571f4fc 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -135,4 +135,14 @@ export const Pages = { SoloPanel: { url: (page: string) => `/d-solo/${page}`, }, + PluginsList: { + page: 'Plugins list page', + list: 'Plugins list', + listItem: 'Plugins list item', + signatureErrorNotice: 'Unsigned plugins notice', + }, + PluginPage: { + page: 'Plugin page', + signatureInfo: 'Plugin signature info', + }, }; diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index 3281824bba9..8ce9a52ea43 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -5,6 +5,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { useTheme } from '../../themes'; import { Icon } from '../Icon/Icon'; import { IconName } from '../../types/icon'; +import { getColorsFromSeverity } from '../../utils/colors'; export type AlertVariant = 'success' | 'warning' | 'error' | 'info'; @@ -76,21 +77,11 @@ export const Alert: FC = ({ }; const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean) => { - const { redBase, redShade, greenBase, greenShade, blue80, blue77, white } = theme.palette; - const backgrounds = { - error: css` - background: linear-gradient(90deg, ${redBase}, ${redShade}); - `, - warning: css` - background: linear-gradient(90deg, ${redBase}, ${redShade}); - `, - info: css` - background: linear-gradient(100deg, ${blue80}, ${blue77}); - `, - success: css` - background: linear-gradient(100deg, ${greenBase}, ${greenShade}); - `, - }; + const { white } = theme.palette; + const severityColors = getColorsFromSeverity(severity, theme); + const background = css` + background: linear-gradient(90deg, ${severityColors[0]}, ${severityColors[0]}); + `; return { container: css` @@ -106,7 +97,7 @@ const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean display: flex; flex-direction: row; align-items: center; - ${backgrounds[severity]} + ${background} `, icon: css` padding: 0 ${theme.spacing.md} 0 0; diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index c51f22e460a..d68c504bd26 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { HTMLAttributes } from 'react'; import { Icon } from '../Icon/Icon'; import { useTheme } from '../../themes/ThemeContext'; import { stylesFactory } from '../../themes/stylesFactory'; @@ -6,23 +6,23 @@ import { IconName } from '../../types'; import { Tooltip } from '../Tooltip/Tooltip'; import { getColorForTheme, GrafanaTheme } from '@grafana/data'; import tinycolor from 'tinycolor2'; -import { css } from 'emotion'; -import { HorizontalGroup } from '..'; +import { css, cx } from 'emotion'; +import { HorizontalGroup } from '../Layout/Layout'; export type BadgeColor = 'blue' | 'red' | 'green' | 'orange' | 'purple'; -export interface BadgeProps { +export interface BadgeProps extends HTMLAttributes { text: string; color: BadgeColor; icon?: IconName; tooltip?: string; } -export const Badge = React.memo(({ icon, color, text, tooltip }) => { +export const Badge = React.memo(({ icon, color, text, tooltip, className, ...otherProps }) => { const theme = useTheme(); const styles = getStyles(theme, color); const badge = ( -
+
{icon && } {text} diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx index 0ecc6da2dfd..68503ebd94e 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx @@ -7,13 +7,22 @@ import { IconButton } from '../IconButton/IconButton'; import { HorizontalGroup } from '../Layout/Layout'; import panelArtDark from './panelArt_dark.svg'; import panelArtLight from './panelArt_light.svg'; +import { AlertVariant } from '../Alert/Alert'; +import { getColorsFromSeverity } from '../../utils/colors'; export interface InfoBoxProps extends Omit, 'title'> { children: React.ReactNode; + /** Title of the box */ title?: string | JSX.Element; + /** Url of the read more link */ url?: string; + /** Text of the read more link */ urlTitle?: string; + /** Indicates whether or not box should be rendered with Grafana branding background */ branded?: boolean; + /** Color variant of the box */ + severity?: AlertVariant; + /** Call back to be performed when box is dismissed */ onDismiss?: () => void; } @@ -24,9 +33,9 @@ export interface InfoBoxProps extends Omit, */ export const InfoBox = React.memo( React.forwardRef( - ({ title, className, children, branded, url, urlTitle, onDismiss, ...otherProps }, ref) => { + ({ title, className, children, branded, url, urlTitle, onDismiss, severity = 'info', ...otherProps }, ref) => { const theme = useTheme(); - const styles = getInfoBoxStyles(theme); + const styles = getInfoBoxStyles(theme, severity); const wrapperClassName = branded ? cx(styles.wrapperBranded, className) : cx(styles.wrapper, className); return ( @@ -49,18 +58,15 @@ export const InfoBox = React.memo( ) ); -const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => ({ +const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme, severity: AlertVariant) => ({ wrapper: css` position: relative; padding: ${theme.spacing.md}; background-color: ${theme.colors.bg2}; - border-top: 3px solid ${theme.palette.blue80}; + border-top: 3px solid ${getColorsFromSeverity(severity, theme)[0]}; margin-bottom: ${theme.spacing.md}; flex-grow: 1; - - ul { - padding-left: ${theme.spacing.lg}; - } + color: ${theme.colors.textSemiWeak}; code { @include font-family-monospace(); @@ -109,5 +115,6 @@ const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => ({ display: inline-block; margin-top: ${theme.spacing.md}; font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textSemiWeak}; `, })); diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index a99cbdb6979..f9dfceac73c 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -20,7 +20,7 @@ export interface SelectCommonProps { filterOption?: (option: SelectableValue, searchQuery: string) => boolean; /** Function for formatting the text that is displayed when creating a new value*/ formatCreateLabel?: (input: string) => string; - getOptionLabel?: (item: SelectableValue) => string; + getOptionLabel?: (item: SelectableValue) => React.ReactNode; getOptionValue?: (item: SelectableValue) => string; inputValue?: string; invalid?: boolean; diff --git a/packages/grafana-ui/src/utils/colors.ts b/packages/grafana-ui/src/utils/colors.ts index 15c393c885b..cf6365557a8 100644 --- a/packages/grafana-ui/src/utils/colors.ts +++ b/packages/grafana-ui/src/utils/colors.ts @@ -6,6 +6,8 @@ import zip from 'lodash/zip'; import tinycolor from 'tinycolor2'; import lightTheme from '../themes/light'; import darkTheme from '../themes/dark'; +import { GrafanaTheme } from '@grafana/data'; +import { AlertVariant } from '../components/Alert/Alert'; export const PALETTE_ROWS = 4; export const PALETTE_COLUMNS = 14; @@ -101,3 +103,21 @@ export function getTextColorForBackground(color: string) { } export let sortedColors = sortColorsByHue(colors); + +/** + * Returns colors used for severity color coding. Use for single color retrievel(0 index) or gradient definition + * @internal + **/ +export function getColorsFromSeverity(severity: AlertVariant, theme: GrafanaTheme): [string, string] { + switch (severity) { + case 'error': + case 'warning': + return [theme.palette.redBase, theme.palette.redShade]; + case 'info': + return [theme.palette.blue80, theme.palette.blue77]; + case 'success': + return [theme.palette.greenBase, theme.palette.greenShade]; + default: + return [theme.palette.blue80, theme.palette.blue77]; + } +} diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 363aec5a3b6..0838a96280d 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -1,5 +1,5 @@ // Libraries -import React, { Component } from 'react'; +import React, { Component, HTMLAttributes } from 'react'; import { getTitleFromNavModel } from 'app/core/selectors/navModel'; // Components @@ -11,7 +11,7 @@ import { NavModel } from '@grafana/data'; import { isEqual } from 'lodash'; import { Branding } from '../Branding/Branding'; -interface Props { +interface Props extends HTMLAttributes { children: React.ReactNode; navModel: NavModel; } @@ -44,13 +44,13 @@ class Page extends Component { } render() { - const { navModel } = this.props; + const { navModel, children, ...otherProps } = this.props; return ( -
+
- {this.props.children} + {children}
diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx index 8cbeceb214f..a2e9eeb4dca 100644 --- a/public/app/core/components/Select/DataSourcePicker.tsx +++ b/public/app/core/components/Select/DataSourcePicker.tsx @@ -2,9 +2,10 @@ import React, { PureComponent } from 'react'; // Components -import { Select } from '@grafana/ui'; +import { HorizontalGroup, Select } from '@grafana/ui'; import { SelectableValue, DataSourceSelectItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../../features/plugins/PluginSignatureBadge'; export interface Props { onChange: (ds: DataSourceSelectItem) => void; @@ -57,6 +58,7 @@ export class DataSourcePicker extends PureComponent { value: ds.name, label: ds.name, imgUrl: ds.meta.info.logos.small, + meta: ds.meta, })); const value = current && { @@ -65,6 +67,7 @@ export class DataSourcePicker extends PureComponent { imgUrl: current.meta.info.logos.small, loading: showLoading, hideText: hideTextValue, + meta: current.meta, }; return ( @@ -85,6 +88,16 @@ export class DataSourcePicker extends PureComponent { noOptionsMessage="No datasources found" value={value} invalid={invalid} + getOptionLabel={o => { + if (isUnsignedPluginSignature(o.meta.signature) && o !== value) { + return ( + + {o.label} + + ); + } + return o.label || ''; + }} />
); diff --git a/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx b/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx index 9adba8b36e4..791aa5256ab 100644 --- a/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx +++ b/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx @@ -3,6 +3,7 @@ import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data'; import { Badge, BadgeProps, styleMixins, stylesFactory, useTheme } from '@grafana/ui'; import { css, cx } from 'emotion'; import { selectors } from '@grafana/e2e-selectors'; +import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../plugins/PluginSignatureBadge'; interface Props { isCurrent: boolean; @@ -135,6 +136,10 @@ interface PanelPluginBadgeProps { const PanelPluginBadge: React.FC = ({ plugin }) => { const display = getPanelStateBadgeDisplayModel(plugin); + if (isUnsignedPluginSignature(plugin.signature)) { + return ; + } + if (plugin.state !== PluginState.deprecated && plugin.state !== PluginState.alpha) { return null; } diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index fc56529ddba..c809291b005 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -13,6 +13,7 @@ import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { setDataSourceTypeSearchQuery } from './state/reducers'; import { PluginSignatureBadge } from '../plugins/PluginSignatureBadge'; import { Card } from 'app/core/components/Card/Card'; +import { PluginsErrorsInfo } from '../plugins/PluginsErrorsInfo'; export interface Props { navModel: NavModel; @@ -98,6 +99,17 @@ class NewDataSourcePage extends PureComponent {
Cancel
+ {!searchQuery && ( + + <> +
+

+ Note that unsigned front-end datasource plugins are still usable, but this is subject + to change in the upcoming releases of Grafana +

+ +
+ )}
{searchQuery && this.renderPlugins(plugins)} {!searchQuery && this.renderCategories()} diff --git a/public/app/features/plugins/PluginList.tsx b/public/app/features/plugins/PluginList.tsx index 01b8b0fbcd5..7ddffba5116 100644 --- a/public/app/features/plugins/PluginList.tsx +++ b/public/app/features/plugins/PluginList.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react'; import PluginListItem from './PluginListItem'; import { PluginMeta } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; interface Props { plugins: PluginMeta[]; @@ -11,7 +12,7 @@ const PluginList: FC = props => { return (
-
    +
      {plugins.map((plugin, index) => { return ; })} diff --git a/public/app/features/plugins/PluginListItem.tsx b/public/app/features/plugins/PluginListItem.tsx index 636efefccdc..e31f25f8bc4 100644 --- a/public/app/features/plugins/PluginListItem.tsx +++ b/public/app/features/plugins/PluginListItem.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react'; import { PluginMeta } from '@grafana/data'; import { PluginSignatureBadge } from './PluginSignatureBadge'; +import { selectors } from '@grafana/e2e-selectors'; interface Props { plugin: PluginMeta; @@ -10,7 +11,7 @@ const PluginListItem: FC = props => { const { plugin } = props; return ( -
    1. +
    2. {plugin.type}
      diff --git a/public/app/features/plugins/PluginListPage.test.tsx b/public/app/features/plugins/PluginListPage.test.tsx index 15ec19af65f..550a473127b 100644 --- a/public/app/features/plugins/PluginListPage.test.tsx +++ b/public/app/features/plugins/PluginListPage.test.tsx @@ -1,11 +1,27 @@ import React from 'react'; -import { shallow } from 'enzyme'; import { PluginListPage, Props } from './PluginListPage'; -import { NavModel, PluginMeta } from '@grafana/data'; +import { NavModel, PluginErrorCode, PluginMeta } from '@grafana/data'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { setPluginsSearchQuery } from './state/reducers'; +import { render, screen, waitFor } from '@testing-library/react'; +import { selectors } from '@grafana/e2e-selectors'; +import { Provider } from 'react-redux'; +import { configureStore } from '../../store/configureStore'; +import { afterEach } from '../../../test/lib/common'; + +let errorsReturnMock: any = []; + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as object), + getBackendSrv: () => ({ + get: () => { + return errorsReturnMock as any; + }, + }), +})); const setup = (propOverrides?: object) => { + const store = configureStore(); const props: Props = { navModel: { main: { @@ -24,21 +40,47 @@ const setup = (propOverrides?: object) => { Object.assign(props, propOverrides); - return shallow(); + return render( + + + + ); }; describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); - - expect(wrapper).toMatchSnapshot(); + afterEach(() => { + errorsReturnMock = []; }); - it('should render list', () => { - const wrapper = setup({ + it('should render component', async () => { + errorsReturnMock = []; + setup(); + await waitFor(() => { + expect(screen.queryByLabelText(selectors.pages.PluginsList.page)).toBeInTheDocument(); + expect(screen.queryByLabelText(selectors.pages.PluginsList.list)).not.toBeInTheDocument(); + }); + }); + + it('should render list', async () => { + errorsReturnMock = []; + setup({ hasFetched: true, }); + await waitFor(() => { + expect(screen.queryByLabelText(selectors.pages.PluginsList.list)).toBeInTheDocument(); + }); + }); - expect(wrapper).toMatchSnapshot(); + describe('Plugin signature errors', () => { + it('should render notice if there are plugins with signing errors', async () => { + errorsReturnMock = [{ pluginId: 'invalid-sig', errorCode: PluginErrorCode.invalidSignature }]; + setup({ + hasFetched: true, + }); + + await waitFor(() => + expect(screen.getByLabelText(selectors.pages.PluginsList.signatureErrorNotice)).toBeInTheDocument() + ); + }); }); }); diff --git a/public/app/features/plugins/PluginListPage.tsx b/public/app/features/plugins/PluginListPage.tsx index b39e4de5979..8632e647bb0 100644 --- a/public/app/features/plugins/PluginListPage.tsx +++ b/public/app/features/plugins/PluginListPage.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import Page from 'app/core/components/Page/Page'; @@ -10,6 +10,9 @@ import { getPlugins, getPluginsSearchQuery } from './state/selectors'; import { NavModel, PluginMeta } from '@grafana/data'; import { StoreState } from 'app/types'; import { setPluginsSearchQuery } from './state/reducers'; +import { useAsync } from 'react-use'; +import { selectors } from '@grafana/e2e-selectors'; +import { PluginsErrorsInfo } from './PluginsErrorsInfo'; export interface Props { navModel: NavModel; @@ -20,40 +23,49 @@ export interface Props { setPluginsSearchQuery: typeof setPluginsSearchQuery; } -export class PluginListPage extends PureComponent { - componentDidMount() { - this.fetchPlugins(); - } +export const PluginListPage: React.FC = ({ + hasFetched, + navModel, + plugins, + setPluginsSearchQuery, + searchQuery, + loadPlugins, +}) => { + useAsync(async () => { + loadPlugins(); + }, [loadPlugins]); - async fetchPlugins() { - await this.props.loadPlugins(); - } + const linkButton = { + href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list', + title: 'Find more plugins on Grafana.com', + }; - render() { - const { hasFetched, navModel, plugins, setPluginsSearchQuery, searchQuery } = this.props; + return ( + + + <> + setPluginsSearchQuery(query)} + linkButton={linkButton} + target="_blank" + /> - const linkButton = { - href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list', - title: 'Find more plugins on Grafana.com', - }; - - return ( - - - <> - setPluginsSearchQuery(query)} - linkButton={linkButton} - target="_blank" - /> - {hasFetched && plugins && plugins && } - - - - ); - } -} + + <> +
      +

      + Note that unsigned front-end datasource and panel plugins are still usable, but this is + subject to change in the upcoming releases of Grafana +

      + +
      + {hasFetched && plugins && } + +
      +
      + ); +}; function mapStateToProps(state: StoreState) { return { diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx index d31687a3d02..bf29f4240a3 100644 --- a/public/app/features/plugins/PluginPage.tsx +++ b/public/app/features/plugins/PluginPage.tsx @@ -14,11 +14,12 @@ import { PluginIncludeType, PluginMeta, PluginMetaInfo, + PluginSignatureStatus, PluginType, UrlQueryMap, } from '@grafana/data'; import { AppNotificationSeverity, CoreEvents, StoreState } from 'app/types'; -import { Alert, Tooltip } from '@grafana/ui'; +import { Alert, InfoBox, Tooltip } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import { getPluginSettings } from './PluginSettingsCache'; @@ -30,6 +31,9 @@ import { PluginDashboards } from './PluginDashboards'; import { appEvents } from 'app/core/core'; import { config } from 'app/core/config'; import { ContextSrv } from '../../core/services/context_srv'; +import { css } from 'emotion'; +import { PluginSignatureBadge } from './PluginSignatureBadge'; +import { selectors } from '@grafana/e2e-selectors'; export function getLoadingNav(): NavModel { const node = { @@ -102,6 +106,7 @@ class PluginPage extends PureComponent { const { appSubUrl } = config; const plugin = await loadPlugin(pluginId); + if (!plugin) { this.setState({ loading: false, @@ -293,13 +298,48 @@ class PluginPage extends PureComponent { ); } + renderPluginNotice() { + const { plugin } = this.state; + + if (!plugin) { + return null; + } + + if (plugin.meta.signature === PluginSignatureStatus.internal) { + return null; + } + + return ( + +

      + +

      +

      + Grafana Labs checks each plugin to verify that it has a valid digital signature. Plugin signature verification + is part of our security measure to ensure plugins are safe and trustworthy. Grafana Labs can’t guarantee the + integrity of this unsigned plugin. Ask the plugin author to request it to be signed. +

      +
      + ); + } + render() { const { loading, nav, plugin } = this.state; const { $contextSrv } = this.props; const isAdmin = $contextSrv.hasRole('Admin'); return ( - + {plugin && (
      @@ -316,6 +356,7 @@ class PluginPage extends PureComponent { } /> )} + {this.renderPluginNotice()} {this.renderBody()}
      @@ -209,7 +171,7 @@ interface ContextMenuItemProps { const ContextMenuItemComponent: React.FC = React.memo( ({ url, icon, label, target, onClick, className }) => { - const theme = useContext(ThemeContext); + const theme = useTheme(); const styles = getContextMenuStyles(theme); return (
      @@ -236,7 +198,7 @@ interface ContextMenuGroupProps { } const ContextMenuGroupComponent: React.FC = ({ group, onClick }) => { - const theme = useContext(ThemeContext); + const theme = useTheme(); const styles = getContextMenuStyles(theme); if (group.items.length === 0) { diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx index 344c017080c..5410ca95cbf 100644 --- a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -2,7 +2,9 @@ import React, { useState } from 'react'; import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu'; interface WithContextMenuProps { + /** Menu item trigger that accepts openMenu prop */ children: (props: { openMenu: React.MouseEventHandler }) => JSX.Element; + /** A function that returns an array of menu items */ getContextMenuItems: () => ContextMenuGroup[]; } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 88a7b8db4b0..172a854d48a 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -104,6 +104,7 @@ export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper'; export * from './SingleStatShared/index'; export { CallToActionCard } from './CallToActionCard/CallToActionCard'; export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu'; +export { WithContextMenu } from './ContextMenu/WithContextMenu'; export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor'; export { DataLinkInput } from './DataLinks/DataLinkInput'; export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; From 283fd4c247c60681ec407274c9ea3f2488e0de36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 27 Oct 2020 17:54:02 +0100 Subject: [PATCH 008/132] API: Reducing some api docs errors (#28575) --- packages/grafana-ui/src/utils/colors.ts | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/src/utils/colors.ts b/packages/grafana-ui/src/utils/colors.ts index cf6365557a8..e5af735c213 100644 --- a/packages/grafana-ui/src/utils/colors.ts +++ b/packages/grafana-ui/src/utils/colors.ts @@ -9,13 +9,36 @@ import darkTheme from '../themes/dark'; import { GrafanaTheme } from '@grafana/data'; import { AlertVariant } from '../components/Alert/Alert'; -export const PALETTE_ROWS = 4; -export const PALETTE_COLUMNS = 14; +const PALETTE_ROWS = 4; + +/** + * @alpha + */ export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)'; + +/** + * @alpha + */ export const OK_COLOR = 'rgba(11, 237, 50, 1)'; + +/** + * @alpha + */ export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)'; + +/** + * @alpha + */ export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)'; + +/** + * @alpha + */ export const PENDING_COLOR = 'rgba(247, 149, 32, 1)'; + +/** + * @alpha + */ export const REGION_FILL_ALPHA = 0.09; export const colors = [ '#7EB26D', // 0: pale green From 5cded1d2cd32ba0c24d192f00e801dd6878f774e Mon Sep 17 00:00:00 2001 From: Anthony D'Atri Date: Tue, 27 Oct 2020 12:57:29 -0700 Subject: [PATCH 009/132] docs: a few tweaks for clarity and readability (#28579) Signed-off-by: Anthony D'Atri --- README.md | 2 +- docs/README.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b185aca3b1b..dc5ebfa27d6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The open-source platform for monitoring and observability. Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data driven culture: -- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins for many different way to visualize metrics and logs. +- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins offer many different ways to visualize metrics and logs. - **Dynamic Dashboards:** Create dynamic & reusable dashboards with template variables that appear as dropdowns at the top of the dashboard. - **Explore Metrics:** Explore your data through ad-hoc queries and dynamic drilldown. Split view and compare different time ranges, queries and data sources side by side. - **Explore Logs:** Experience the magic of switching from metrics to logs with preserved label filters. Quickly search through all your logs or streaming them live. diff --git a/docs/README.md b/docs/README.md index 8ca41d57852..305282120c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,8 +9,8 @@ Yarn >= 1.22.4 ## Build the doc site -1. In the command line, make sure you are in the docs folder: `cd docs`. -1. Run `make docs`. This launches a preview of the docs website at `http://localhost:3002/docs/grafana/latest/` which will refresh automatically when changes to content in the `sources` directory are made. +1. On the command line, first change to the docs folder: `cd docs`. +1. Run `make docs`. This launches a preview of the docs website at `http://localhost:3002/docs/grafana/latest/` which will refresh automatically when changes are made to content in the `sources` directory. --- @@ -34,6 +34,6 @@ Images are currently hosted in the grafana/website repo. ## Deploy changes to grafana.com -When a PR is merged to master with changes in the `docs/sources` directory, those changes are automatically synched to the grafana/website repo and published to the staging site. +When a PR is merged to master with changes in the `docs/sources` directory, those changes are automatically synced to the grafana/website repo and published to the staging site. -Generally, someone from marketing will publish to production each day, so as long as the sync is successful your docs edits will be published. Alternatively, you can refer to [publishing to production](https://github.com/grafana/website#publishing-to-production-grafanacom) if you'd like to do it yourself. \ No newline at end of file +Generally, someone from marketing will publish to production each day: so as long as the sync is successful your docs edits will be published. Alternatively, you can refer to [publishing to production](https://github.com/grafana/website#publishing-to-production-grafanacom) if you'd like to do it yourself. From 0d803613d6d0e8915c9a8cb75951f647bd4c9a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 28 Oct 2020 08:04:30 +0100 Subject: [PATCH 010/132] StatPanel: Fixes BizChart error max: yyy should not be less than min zzz (#28587) --- .../src/field/fieldOverrides.test.ts | 19 ++++++++++++++++-- .../grafana-data/src/field/fieldOverrides.ts | 20 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index f7e26bfadbc..44084927f6f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -11,14 +11,14 @@ import { MutableDataFrame, toDataFrame } from '../dataframe'; import { DataFrame, Field, + FieldColorModeId, FieldConfig, FieldConfigPropertyItem, FieldConfigSource, FieldType, InterpolateFunction, - ThresholdsMode, - FieldColorModeId, ScopedVars, + ThresholdsMode, } from '../types'; import { locationUtil, Registry } from '../utils'; import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; @@ -87,6 +87,21 @@ describe('Global MinMax', () => { expect(minmax.min).toEqual(-20); expect(minmax.max).toEqual(1234); }); + + describe('when value is null', () => { + it('then global min max should be null', () => { + const frame = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1] }, + { name: 'Value', type: FieldType.number, values: [null] }, + ], + }); + const { min, max } = findNumericFieldMinMax([frame]); + + expect(min).toBeNull(); + expect(max).toBeNull(); + }); + }); }); describe('applyFieldOverrides', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index fad547fc982..f1bd357e47f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -55,11 +55,23 @@ export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax { for (const field of frame.fields) { if (field.type === FieldType.number) { const stats = reduceField({ field, reducers }); - if (stats[ReducerID.min] < min) { - min = stats[ReducerID.min]; + const statsMin = stats[ReducerID.min]; + const statsMax = stats[ReducerID.max]; + + if (!statsMin) { + min = statsMin; } - if (stats[ReducerID.max] > max) { - max = stats[ReducerID.max]; + + if (!statsMax) { + max = statsMax; + } + + if (statsMin && statsMin < min) { + min = statsMin; + } + + if (statsMax && statsMax > max) { + max = statsMax; } } } From 33ef71d81e880f40e61213e8f7fd5b4b35fc801c Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 28 Oct 2020 00:15:32 -0700 Subject: [PATCH 011/132] AddDatasource: Improve plugin categories (#28584) * add IoT category to datasources * add more enterprise plugins * add more enterprise plugins --- .../datasources/state/buildCategories.test.ts | 16 ++++++------- .../datasources/state/buildCategories.ts | 24 +++++++++++++++++-- public/img/plugins/mongodb.svg | 2 ++ public/img/plugins/snowflake.svg | 2 ++ public/img/plugins/wavefront.svg | 17 +++++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 public/img/plugins/mongodb.svg create mode 100644 public/img/plugins/snowflake.svg create mode 100644 public/img/plugins/wavefront.svg diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts index e95b4bb0e7b..f2c8fcfd622 100644 --- a/public/app/features/datasources/state/buildCategories.test.ts +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -28,8 +28,8 @@ const plugins: DataSourcePluginMeta[] = [ describe('buildCategories', () => { const categories = buildCategories(plugins); - it('should group plugins into categories', () => { - expect(categories.length).toBe(7); + it('should group plugins into categories and remove empty categories', () => { + expect(categories.length).toBe(4); expect(categories[0].title).toBe('Time series databases'); expect(categories[0].plugins.length).toBe(2); expect(categories[1].title).toBe('Logging & document databases'); @@ -40,17 +40,17 @@ describe('buildCategories', () => { }); it('should add phantom plugin for Grafana cloud', () => { - expect(categories[4].title).toBe('Cloud'); - expect(categories[4].plugins.length).toBe(2); - expect(categories[4].plugins[1].id).toBe('gcloud'); + expect(categories[2].title).toBe('Cloud'); + expect(categories[2].plugins.length).toBe(2); + expect(categories[2].plugins[1].id).toBe('gcloud'); }); it('should set module to phantom on phantom plugins', () => { - expect(categories[5].plugins[0].module).toBe('phantom'); + expect(categories[3].plugins[0].module).toBe('phantom'); }); it('should add enterprise phantom plugins', () => { - expect(categories[5].title).toBe('Enterprise plugins'); - expect(categories[5].plugins.length).toBe(7); + expect(categories[3].title).toBe('Enterprise plugins'); + expect(categories[3].plugins.length).toBe(10); }); }); diff --git a/public/app/features/datasources/state/buildCategories.ts b/public/app/features/datasources/state/buildCategories.ts index 56f426692e8..f2235d94fdf 100644 --- a/public/app/features/datasources/state/buildCategories.ts +++ b/public/app/features/datasources/state/buildCategories.ts @@ -9,6 +9,7 @@ export function buildCategories(plugins: DataSourcePluginMeta[]): DataSourcePlug { id: 'sql', title: 'SQL', plugins: [] }, { id: 'cloud', title: 'Cloud', plugins: [] }, { id: 'enterprise', title: 'Enterprise plugins', plugins: [] }, + { id: 'iot', title: 'Industrial & IoT', plugins: [] }, { id: 'other', title: 'Others', plugins: [] }, ].filter(item => item); @@ -23,7 +24,7 @@ export function buildCategories(plugins: DataSourcePluginMeta[]): DataSourcePlug for (const plugin of plugins) { // Force category for enterprise plugins - if (enterprisePlugins.find(item => item.id === plugin.id)) { + if (plugin.enterprise || enterprisePlugins.find(item => item.id === plugin.id)) { plugin.category = 'enterprise'; } @@ -58,7 +59,8 @@ export function buildCategories(plugins: DataSourcePluginMeta[]): DataSourcePlug sortPlugins(category.plugins); } - return categories; + // Only show categories with plugins + return categories.filter(c => c.plugins.length > 0); } function sortPlugins(plugins: DataSourcePluginMeta[]) { @@ -124,6 +126,24 @@ function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] { name: 'New Relic', imgUrl: 'public/img/plugins/newrelic.svg', }), + getPhantomPlugin({ + id: 'grafana-mongodb-datasource', + description: 'MongoDB integration & data source', + name: 'MongoDB', + imgUrl: 'public/img/plugins/mongodb.svg', + }), + getPhantomPlugin({ + id: 'grafana-snowflake-datasource', + description: 'Snowflake integration & data source', + name: 'Snowflake', + imgUrl: 'public/img/plugins/snowflake.svg', + }), + getPhantomPlugin({ + id: 'grafana-wavefront-datasource', + description: 'Wavefront integration & data source', + name: 'Wavefront', + imgUrl: 'public/img/plugins/wavefront.svg', + }), getPhantomPlugin({ id: 'dlopes7-appdynamics-datasource', description: 'AppDynamics integration & data source', diff --git a/public/img/plugins/mongodb.svg b/public/img/plugins/mongodb.svg new file mode 100644 index 00000000000..01a953800ea --- /dev/null +++ b/public/img/plugins/mongodb.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/img/plugins/snowflake.svg b/public/img/plugins/snowflake.svg new file mode 100644 index 00000000000..cd2263db996 --- /dev/null +++ b/public/img/plugins/snowflake.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/img/plugins/wavefront.svg b/public/img/plugins/wavefront.svg new file mode 100644 index 00000000000..35b48980714 --- /dev/null +++ b/public/img/plugins/wavefront.svg @@ -0,0 +1,17 @@ + + + + + + image/svg+xml + + + + + + + + + + + From c96ef2676ea8df703f6e4c665ed11d069a1c2147 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 28 Oct 2020 09:23:22 +0200 Subject: [PATCH 012/132] Grafana-UI: Add Card component (#28216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Base card * Add disabled state * Expand knobs * Add card actions * Add meta data * Allow custom tags * Extend container props * Add inner link * Add docs * Add missing keys * Update margins * Add description * Add full card example * Tweak disabld state * Export Card * title => heading * Filter out empty content * Add disableEvents * Move tooltip to container * Use new Card for AlertRuleItem * Cleanup * Update snapshot * Rename props * Rename props[2] * Disable hover is onClick is missing * Fix alert rule item * Update snapshot * Export CardProps * Replace logo * Remove tag prop * Remove extra div * Add @public * Update AlertRuleItem * Simplify disabled logic * Export Card styles * Remove AlertRuleItem tooltips * Revert to old button design * Make component internal Co-authored-by: Torkel Ödegaard --- .../grafana-ui/src/components/Card/Card.mdx | 282 ++++++++++++++++++ .../components/Card/Card.story.internal.tsx | 156 ++++++++++ .../grafana-ui/src/components/Card/Card.tsx | 245 +++++++++++++++ .../grafana-ui/src/components/Tags/Tag.tsx | 3 + .../src/components/Tags/TagList.tsx | 4 +- packages/grafana-ui/src/components/index.ts | 3 +- .../app/features/alerting/AlertRuleItem.tsx | 92 +++--- .../__snapshots__/AlertRuleItem.test.tsx.snap | 132 ++++---- 8 files changed, 792 insertions(+), 125 deletions(-) create mode 100644 packages/grafana-ui/src/components/Card/Card.mdx create mode 100644 packages/grafana-ui/src/components/Card/Card.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Card/Card.tsx diff --git a/packages/grafana-ui/src/components/Card/Card.mdx b/packages/grafana-ui/src/components/Card/Card.mdx new file mode 100644 index 00000000000..d98f13e84ce --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.mdx @@ -0,0 +1,282 @@ +import { Meta, Preview, Props } from "@storybook/addon-docs/blocks"; +import { Card } from "./Card"; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; + +export const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png' + + + +# Card + +## Usage + +### Basic +A basic Card component expects at least a heading to be used as title. Optionally a `metadata` prop is accepted, to provide some secondary information for the card. Multiple meta data elements can be provided as an array, in which case they will be separated by a horizontal line: `|`. +```jsx + +``` + + + + + +### Multiple metadata elements + +```jsx + +``` + + + console.log('clicked tag:', tag) } + /> + + +Metadata also accepts html elements, which could be links, for example. For elements, that are not strings, a `key` prop has to be manually specified. + +```jsx +https://ops-us-east4.grafana.net/api/prom, + ]} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + /> + + +### As a link +Card can be used as a clickable link item by specifying `href` prop. In this case the Card's content will be rendered inside `a`. + +```jsx + +``` + + + + +### Inside a list item + +To render cards in a list, it is possible to nest them inside `li` items. +```jsx +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +``` + +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
      + +### With media elements + +Cards can also be rendered with media content such icons or images. + +```jsx +https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + /> + + +### Action Cards + +Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons while secondary actions are displayed as icon buttons. + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, + , + ]} + secondaryActions={[ + , + , + ]} + /> + + +### Disabled state + +Card can have a disabled state, effectively making it and its actions non-clickable. If there is more than one primary action, disabled state will disable them instead of the whole card. + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + disabled +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + disabled + /> + + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} + disabled +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} + disabled + /> + + + +### Props + + diff --git a/packages/grafana-ui/src/components/Card/Card.story.internal.tsx b/packages/grafana-ui/src/components/Card/Card.story.internal.tsx new file mode 100644 index 00000000000..533c08da559 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.story.internal.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { boolean } from '@storybook/addon-knobs'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { Card } from './Card'; +import mdx from './Card.mdx'; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; + +const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'; + +export default { + title: 'General/Card', + component: Card, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const getKnobs = () => { + const disabled = boolean('Disabled', false, 'Style props'); + + return { disabled }; +}; + +export const Basic = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const AsLink = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithTooltip = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithTags = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithMedia = () => { + const { disabled } = getKnobs(); + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + /> + ); +}; +export const WithActions = () => { + const { disabled } = getKnobs(); + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + actions={[ + , + , + ]} + secondaryActions={[ + , + , + ]} + /> + ); +}; + +export const Full = () => { + const { disabled } = getKnobs(); + + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + tags={['firing', 'active', 'test', 'testdata', 'prometheus']} + description="Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + actions={[ + , + , + ]} + secondaryActions={[ + , + , + , + , + , + ]} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx new file mode 100644 index 00000000000..45bc6e43be8 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -0,0 +1,245 @@ +import React, { cloneElement, FC, HTMLAttributes, ReactElement, ReactNode, useCallback, useMemo } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useTheme, styleMixins, stylesFactory } from '../../themes'; +import { Tooltip, PopoverContent } from '../Tooltip/Tooltip'; +import { OnTagClick } from '../Tags/Tag'; +import { TagList } from '../Tags/TagList'; + +/** + * @alpha + */ +export interface ContainerProps extends HTMLAttributes { + /** Content for the card's tooltip */ + tooltip?: PopoverContent; +} + +const CardContainer: FC = ({ children, tooltip, ...props }) => { + return tooltip ? ( + +
      {children}
      +
      + ) : ( +
      {children}
      + ); +}; + +/** + * @alpha + */ +export interface CardInnerProps { + href?: string; +} + +const CardInner: FC = ({ children, href }) => { + const theme = useTheme(); + const styles = getCardStyles(theme); + return href ? ( + + {children} + + ) : ( + <>{children} + ); +}; + +/** + * @alpha + */ +export interface Props extends ContainerProps { + /** Main heading for the Card **/ + heading: ReactNode; + /** Additional data about the card. If array is supplied, elements will be rendered with vertical line separator */ + metadata?: ReactNode | ReactNode[]; + /** Card description text */ + description?: string; + /** List of tags to display in the card */ + tags?: string[]; + /** Optional callback for tag onclick event */ + onTagClick?: OnTagClick; + /** Indicates if the card and all its actions can be interacted with */ + disabled?: boolean; + /** Image or icon to be displayed on the let side of the card */ + image?: ReactNode; + /** Main card actions **/ + actions?: ReactElement[]; + /** Right-side actions */ + secondaryActions?: ReactElement[]; + /** Link to redirect to on card click. If provided, the Card inner content will be rendered inside `a` */ + href?: string; + /** On click handler for the Card */ + onClick?: () => void; +} + +/** + * Generic card component + * + * @alpha + */ +export const Card: FC = ({ + heading, + description, + metadata, + tags = [], + onTagClick, + disabled, + image, + actions = [], + tooltip, + secondaryActions = [], + href, + onClick, + className, + ...htmlProps +}) => { + const hasActions = Boolean(actions.length || secondaryActions.length); + const disableHover = disabled || actions.length > 1 || !onClick; + const disableEvents = disabled && !actions.length; + const theme = useTheme(); + const styles = getCardStyles(theme, disableEvents, disableHover); + // Join meta data elements by '|' + const meta = useMemo( + () => + Array.isArray(metadata) + ? (metadata as ReactNode[]).filter(Boolean).reduce((prev, curr, i) => [ + prev, + + | + , + curr, + ]) + : metadata, + [metadata, styles.separator] + ); + const onCardClick = useCallback(() => (disableHover ? () => {} : onClick), [disableHover, onClick]); + + return ( + + + {image &&
      {image}
      } +
      +
      {heading}
      + {meta &&
      {meta}
      } + {!!tags.length && } + {description &&

      {description}

      } + {hasActions && ( +
      + {!!actions.length && ( +
      {actions.map(action => cloneElement(action, { disabled }))}
      + )} + {!!secondaryActions.length && ( +
      + {secondaryActions.map(action => cloneElement(action, { disabled }))} +
      + )} +
      + )} +
      +
      +
      + ); +}; + +/** + * @alpha + */ +export const getCardStyles = stylesFactory((theme: GrafanaTheme, disabled = false, disableHover = false) => { + return { + container: css` + display: flex; + width: 100%; + color: ${theme.colors.textStrong}; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing.md}; + position: relative; + pointer-events: ${disabled ? 'none' : 'auto'}; + margin-bottom: ${theme.spacing.sm}; + + &::after { + content: ''; + display: ${disabled ? 'block' : 'none'}; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + background: linear-gradient(180deg, rgba(75, 79, 84, 0.5) 0%, rgba(82, 84, 92, 0.5) 100%); + width: calc(100% - 2px); + height: calc(100% - 2px); + border-radius: ${theme.border.radius.sm}; + } + + &:hover { + background: ${disableHover ? theme.colors.bg2 : styleMixins.hoverColor(theme.colors.bg2, theme)}; + cursor: ${disableHover ? 'default' : 'pointer'}; + } + + &:focus { + ${styleMixins.focusCss(theme)}; + } + `, + inner: css` + width: 100%; + `, + heading: css` + margin-bottom: 0; + font-size: ${theme.typography.size.md}; + line-height: ${theme.typography.lineHeight.xs}; + `, + metadata: css` + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textSemiWeak}; + margin: ${theme.spacing.sm} 0 0; + line-height: ${theme.typography.lineHeight.xs}; + `, + description: css` + margin: ${theme.spacing.sm} 0 0; + color: ${theme.colors.textSemiWeak}; + line-height: ${theme.typography.lineHeight.md}; + `, + media: css` + margin-right: ${theme.spacing.md}; + max-width: 40px; + & > * { + width: 100%; + } + `, + actionRow: css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: ${theme.spacing.md}; + `, + actions: css` + & > * { + margin-right: ${theme.spacing.sm}; + } + `, + secondaryActions: css` + display: flex; + align-items: center; + color: ${theme.colors.textSemiWeak}; + & > * { + margin-right: ${theme.spacing.sm} !important; + } + `, + separator: css` + margin: 0 ${theme.spacing.sm}; + `, + innerLink: css` + display: flex; + width: 100%; + `, + tagList: css` + margin-top: ${theme.spacing.sm}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index 275d14d0764..9d18a88565a 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -4,6 +4,9 @@ import { GrafanaTheme } from '@grafana/data'; import { useTheme } from '../../themes'; import { getTagColor, getTagColorsFromName } from '../../utils'; +/** + * @public + */ export type OnTagClick = (name: string, event: React.MouseEvent) => any; export interface Props extends Omit, 'onClick'> { diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index 844342ee18e..cb3ca0b486d 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -29,7 +29,9 @@ const getStyles = () => { flex-wrap: wrap; `, tag: css` - margin-left: 6px; + &:not(:first-child) { + margin-left: 6px; + } `, }; }; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 172a854d48a..a42b11cbf90 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -29,7 +29,7 @@ export { TimeZonePicker } from './TimePicker/TimeZonePicker'; export { List } from './List/List'; export { TagsInput } from './TagsInput/TagsInput'; export { Pagination } from './Pagination/Pagination'; -export { Tag } from './Tags/Tag'; +export { Tag, OnTagClick } from './Tags/Tag'; export { TagList } from './Tags/TagList'; export { FilterPill } from './FilterPill/FilterPill'; @@ -169,6 +169,7 @@ export { Checkbox } from './Forms/Checkbox'; export { TextArea } from './TextArea/TextArea'; export { FileUpload } from './FileUpload/FileUpload'; export { TimeRangeInput } from './TimePicker/TimeRangeInput'; +export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card'; // Legacy forms diff --git a/public/app/features/alerting/AlertRuleItem.tsx b/public/app/features/alerting/AlertRuleItem.tsx index c01c6105e8f..532a82f101f 100644 --- a/public/app/features/alerting/AlertRuleItem.tsx +++ b/public/app/features/alerting/AlertRuleItem.tsx @@ -1,8 +1,9 @@ -import React, { PureComponent } from 'react'; +import React, { useCallback } from 'react'; // @ts-ignore import Highlighter from 'react-highlight-words'; +import { css } from 'emotion'; +import { Icon, IconName, Button, LinkButton, Card } from '@grafana/ui'; import { AlertRule } from '../../types'; -import { Icon, IconName, Button, Tooltip, LinkButton, HorizontalGroup } from '@grafana/ui'; export interface Props { rule: AlertRule; @@ -10,56 +11,51 @@ export interface Props { onTogglePause: () => void; } -class AlertRuleItem extends PureComponent { - renderText(text: string) { - return ( +const AlertRuleItem = ({ rule, search, onTogglePause }: Props) => { + const ruleUrl = `${rule.url}?editPanel=${rule.panelId}&tab=alert`; + const renderText = useCallback( + text => ( - ); - } + ), + [search] + ); - render() { - const { rule, onTogglePause } = this.props; - - const ruleUrl = `${rule.url}?editPanel=${rule.panelId}&tab=alert`; - - return ( -
    3. - -
      -
      - -
      - {this.renderText(rule.stateText)} - for {rule.stateAge} -
      -
      - {rule.info &&
      {this.renderText(rule.info)}
      } -
      - -
      - - -
      -
    4. - ); - } -} + return ( +
    5. + {renderText(rule.name)}} + image={ + + } + metadata={[ + + + {renderText(rule.stateText)}{' '} + + for {rule.stateAge} + , + rule.info ? renderText(rule.info) : null, + ]} + actions={[ + , + + Edit alert + , + ]} + /> +
    6. + ); +}; export default AlertRuleItem; diff --git a/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap index 4a588d10caf..2e77c00710a 100644 --- a/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap @@ -2,90 +2,72 @@ exports[`Render should render component 1`] = `
    7. - -
      -
      -
      - - - -
      -
      - - - - - for - age - -
      -
      -
      -
      - - + - - + > + Pause + , + Edit alert + , + ] + } + heading={ + + - - -
      + + } + image={ + + } + metadata={ + Array [ + + + + + + for + age + , + null, + ] + } + />
    8. `; From e94b37c656b0d0acf002e94e9ddc030eda848898 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 28 Oct 2020 09:16:10 +0100 Subject: [PATCH 013/132] Explore/Loki: Update docs and cheatsheet (#28541) * Add updated histogram docs * Add to cheatsheet * Update * Update docs/sources/explore/index.md * Update docs/sources/explore/index.md --- docs/sources/explore/index.md | 6 +++++- .../plugins/datasource/loki/components/LokiCheatSheet.tsx | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/sources/explore/index.md b/docs/sources/explore/index.md index b1039ac2cb5..59c35daa1ec 100644 --- a/docs/sources/explore/index.md +++ b/docs/sources/explore/index.md @@ -155,6 +155,10 @@ Along with metrics, Explore allows you to investigate your logs with the followi - [InfluxDB](../datasources/influxdb) - [Elasticsearch](../datasources/elasticsearch) +### Logs visualization + +Results of log queries are shown as histograms in the graph and individual logs are displayed below. If the data source does not send histogram data for the requested time range, the logs model computes a time series based on the log row counts bucketed by an automatically calculated time interval and the start of the histogram is then anchored by the first log row's timestamp from the result. The end of the time series is anchored to the time picker's **To** range. + ### Visualization options You can customize how logs are displayed and select which columns are shown. @@ -297,4 +301,4 @@ This functionality is similar to the panel inspector [Stats tab]({{< relref "../ {{< docs-imagebox img="/img/docs/v71/query_inspector_explore.png" class="docs-image--no-shadow" caption="Screenshot of the query inspector button in Explore" >}} - \ No newline at end of file + diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx index 56812dc719f..a7974a2ab5d 100644 --- a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -8,6 +8,12 @@ const PREFERRED_LABELS = ['job', 'app', 'k8s_app']; const EXAMPLES_LIMIT = 5; const LOGQL_EXAMPLES = [ + { + title: 'Log pipeline', + expression: '{job="mysql"} |= "metrics" | logfmt | duration > 10s', + label: + 'This query targets the MySQL job, filters out logs that don’t contain the word "metrics" and parses each log line to extract more labels and filters with them.', + }, { title: 'Count over time', expression: 'count_over_time({job="mysql"}[5m])', From c4c5b2dc619a52e3f4166069dd41f24458c0dc54 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Wed, 28 Oct 2020 08:36:57 +0000 Subject: [PATCH 014/132] CloudWatch Logs queue and websocket support (#28176) CloudWatch Logs queue and websocket support --- go.mod | 5 +- go.sum | 7 + pkg/api/api.go | 6 +- pkg/api/dashboard.go | 2 +- pkg/api/dashboard_test.go | 3 + pkg/api/http_server.go | 11 +- pkg/registry/registry.go | 5 +- pkg/server/server.go | 2 +- pkg/services/live/live.go | 81 +++-- pkg/tsdb/cloudwatch/cloudwatch.go | 44 ++- pkg/tsdb/cloudwatch/live.go | 319 ++++++++++++++++++ pkg/tsdb/cloudwatch/log_actions_test.go | 14 +- pkg/tsdb/cloudwatch/logs.go | 63 ++++ pkg/tsdb/cloudwatch/metric_find_query_test.go | 12 +- pkg/tsdb/cloudwatch/query_transformer_test.go | 2 +- pkg/tsdb/cloudwatch/session_test.go | 4 +- pkg/tsdb/cloudwatch/time_series_query_test.go | 2 +- pkg/util/retryer/retryer.go | 56 +++ pkg/util/retryer/retryer_test.go | 22 ++ .../components/MetricsQueryEditor.test.tsx | 2 +- .../datasource/cloudwatch/datasource.ts | 126 ++++++- .../plugins/datasource/cloudwatch/module.tsx | 8 +- .../plugins/datasource/cloudwatch/types.ts | 1 + 23 files changed, 701 insertions(+), 96 deletions(-) create mode 100644 pkg/tsdb/cloudwatch/live.go create mode 100644 pkg/tsdb/cloudwatch/logs.go create mode 100644 pkg/util/retryer/retryer.go create mode 100644 pkg/util/retryer/retryer_test.go diff --git a/go.mod b/go.mod index 66ece421511..4f4f29cedc2 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( cloud.google.com/go/storage v1.12.0 github.com/BurntSushi/toml v0.3.1 github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f - github.com/aws/aws-sdk-go v1.33.12 + github.com/aws/aws-sdk-go v1.35.5 github.com/beevik/etree v1.1.0 github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b @@ -40,6 +40,7 @@ require ( github.com/golang/mock v1.4.4 github.com/golang/protobuf v1.4.3 github.com/google/go-cmp v0.5.2 + github.com/google/uuid v1.1.2 github.com/gosimple/slug v1.4.2 github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 github.com/grafana/grafana-plugin-sdk-go v0.78.0 @@ -50,7 +51,7 @@ require ( github.com/hashicorp/go-version v1.2.0 github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec github.com/influxdata/influxdb-client-go/v2 v2.0.1 - github.com/jmespath/go-jmespath v0.3.0 + github.com/jmespath/go-jmespath v0.4.0 github.com/jonboulle/clockwork v0.2.1 // indirect github.com/jung-kurt/gofpdf v1.10.1 github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect diff --git a/go.sum b/go.sum index ff845928962..9ed47846277 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU github.com/aws/aws-sdk-go v1.33.5/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.33.12 h1:eydMoSwfrSTD9PWKUJOiDL7+/UwDW8AjInUGVE5Llh4= github.com/aws/aws-sdk-go v1.33.12/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.35.5 h1:doSEOxC0UkirPcle20Rc+1kAhJ4Ip+GSEeZ3nKl7Qlk= +github.com/aws/aws-sdk-go v1.35.5/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= @@ -718,6 +720,10 @@ github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= @@ -1067,6 +1073,7 @@ github.com/samuel/go-zookeeper v0.0.0-20200724154423-2164a8ac840e/go.mod h1:gi+0 github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= github.com/satori/go.uuid v0.0.0-20160603004225-b111a074d5ef/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e/go.mod h1:tm/wZFQ8e24NYaBGIlnO2WGCAi67re4HHuOm0sftE/M= diff --git a/pkg/api/api.go b/pkg/api/api.go index ebd5ff713ed..7986b0be5bf 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/models" ) +// registerRoutes registers all API HTTP routes. func (hs *HTTPServer) registerRoutes() { reqSignedIn := middleware.ReqSignedIn reqGrafanaAdmin := middleware.ReqGrafanaAdmin @@ -435,11 +436,6 @@ func (hs *HTTPServer) registerRoutes() { avatarCacheServer := avatar.NewCacheServer() r.Get("/avatar/:hash", avatarCacheServer.Handler) - // Live streaming - if hs.Live != nil { - r.Any("/live/*", hs.Live.WebsocketHandler) - } - // Snapshots r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, bind(models.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 13abc19e7f4..9189ffeaed1 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -262,7 +262,7 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext, cmd models.SaveDashboa } // Tell everyone listening that the dashboard changed - if hs.Live != nil { + if hs.Live.IsEnabled() { err := hs.Live.GrafanaScope.Dashboards.DashboardSaved( dashboard.Uid, c.UserId, diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index c1a7926fb89..fea3e84eb5a 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" @@ -1129,6 +1130,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d Bus: bus.GetBus(), Cfg: setting.NewCfg(), ProvisioningService: provisioning.NewProvisioningServiceMock(), + Live: &live.GrafanaLive{Cfg: setting.NewCfg()}, } sc := setupScenarioContext(url) @@ -1188,6 +1190,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin Cfg: setting.NewCfg(), Bus: bus.GetBus(), ProvisioningService: provisioning.NewProvisioningServiceMock(), + Live: &live.GrafanaLive{Cfg: setting.NewCfg()}, } sc := setupScenarioContext(url) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index b1076c0de0a..a06330470e2 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -75,22 +75,13 @@ type HTTPServer struct { SearchService *search.SearchService `inject:""` AlertNG *eval.AlertNG `inject:""` ShortURLService *shorturls.ShortURLService `inject:""` - Live *live.GrafanaLive + Live *live.GrafanaLive `inject:""` Listener net.Listener } func (hs *HTTPServer) Init() error { hs.log = log.New("http.server") - // Set up a websocket broker - if hs.Cfg.IsLiveEnabled() { // feature flag - node, err := live.InitializeBroker() - if err != nil { - return err - } - hs.Live = node - } - hs.macaron = hs.newMacaron() hs.registerRoutes() diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 5894a657286..be3497f7ad8 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -70,7 +70,6 @@ func getServicesWithOverrides() []*Descriptor { // Service interface is the lowest common shape that services // are expected to fulfill to be started within Grafana. type Service interface { - // Init is called by Grafana main process which gives the service // the possibility do some initial work before its started. Things // like adding routes, bus handlers should be done in the Init function @@ -82,7 +81,6 @@ type Service interface { // that might not always be started, ex alerting. // This will be called after `Init()`. type CanBeDisabled interface { - // IsDisabled should return a bool saying if it can be started or not. IsDisabled() bool } @@ -99,13 +97,12 @@ type BackgroundService interface { // DatabaseMigrator allows the caller to add migrations to // the migrator passed as argument type DatabaseMigrator interface { - // AddMigrations allows the service to add migrations to // the database migrator. AddMigration(mg *migrator.Migrator) } -// IsDisabled takes an service and return true if its disabled +// IsDisabled returns whether a service is disabled. func IsDisabled(srv Service) bool { canBeDisabled, ok := srv.(CanBeDisabled) return ok && canBeDisabled.IsDisabled() diff --git a/pkg/server/server.go b/pkg/server/server.go index 269197a5b91..ffca89db67b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -293,7 +293,7 @@ func (s *Server) buildServiceGraph(services []*registry.Descriptor) error { // Resolve services and their dependencies. if err := serviceGraph.Populate(); err != nil { - return errutil.Wrapf(err, "Failed to populate service dependency") + return errutil.Wrapf(err, "Failed to populate service dependencies") } return nil diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 9d9fe981f11..052f75b9943 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -7,10 +7,14 @@ import ( "sync" "github.com/centrifugal/centrifuge" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/live/features" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch" ) var ( @@ -18,6 +22,16 @@ var ( loggerCF = log.New("live.centrifuge") ) +func init() { + registry.RegisterService(&GrafanaLive{ + channels: make(map[string]models.ChannelHandler), + channelsMu: sync.RWMutex{}, + GrafanaScope: CoreGrafanaScope{ + Features: make(map[string]models.ChannelHandlerFactory), + }, + }) +} + // CoreGrafanaScope list of core features type CoreGrafanaScope struct { Features map[string]models.ChannelHandlerFactory @@ -28,7 +42,10 @@ type CoreGrafanaScope struct { // GrafanaLive pretends to be the server type GrafanaLive struct { - node *centrifuge.Node + Cfg *setting.Cfg `inject:""` + RouteRegister routing.RouteRegister `inject:""` + LogsService *cloudwatch.LogsService `inject:""` + node *centrifuge.Node // The websocket handler WebsocketHandler interface{} @@ -41,14 +58,14 @@ type GrafanaLive struct { GrafanaScope CoreGrafanaScope } -// InitializeBroker initializes the broker and starts listening for requests. -func InitializeBroker() (*GrafanaLive, error) { - glive := &GrafanaLive{ - channels: make(map[string]models.ChannelHandler), - channelsMu: sync.RWMutex{}, - GrafanaScope: CoreGrafanaScope{ - Features: make(map[string]models.ChannelHandlerFactory), - }, +// Init initializes the instance. +// Required to implement the registry.Service interface. +func (g *GrafanaLive) Init() error { + logger.Debug("GrafanaLive initing") + + if !g.IsEnabled() { + logger.Debug("GrafanaLive feature not enabled, skipping initialization") + return nil } // We use default config here as starting point. Default config contains @@ -60,7 +77,7 @@ func InitializeBroker() (*GrafanaLive, error) { // This function is called fast and often -- it must be sychronized cfg.ChannelOptionsFunc = func(channel string) (centrifuge.ChannelOptions, bool, error) { - handler, err := glive.GetChannelHandler(channel) + handler, err := g.GetChannelHandler(channel) if err != nil { logger.Error("ChannelOptionsFunc", "channel", channel, "err", err) if err.Error() == "404" { // ???? @@ -78,22 +95,22 @@ func InitializeBroker() (*GrafanaLive, error) { // only from client side. node, err := centrifuge.New(cfg) if err != nil { - return nil, err + return err } - glive.node = node + g.node = node // Initialize the main features dash := &features.DashboardHandler{ - Publisher: glive.Publish, + Publisher: g.Publish, } - glive.GrafanaScope.Dashboards = dash - glive.GrafanaScope.Features["dashboard"] = dash - glive.GrafanaScope.Features["testdata"] = &features.TestDataSupplier{ - Publisher: glive.Publish, + g.GrafanaScope.Dashboards = dash + g.GrafanaScope.Features["dashboard"] = dash + g.GrafanaScope.Features["testdata"] = &features.TestDataSupplier{ + Publisher: g.Publish, } - glive.GrafanaScope.Features["broadcast"] = &features.BroadcastRunner{} - glive.GrafanaScope.Features["measurements"] = &features.MeasurementsRunner{} + g.GrafanaScope.Features["broadcast"] = &features.BroadcastRunner{} + g.GrafanaScope.Features["measurements"] = &features.MeasurementsRunner{} // Set ConnectHandler called when client successfully connected to Node. Your code // inside handler must be synchronized since it will be called concurrently from @@ -121,7 +138,7 @@ func InitializeBroker() (*GrafanaLive, error) { node.OnSubscribe(func(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { reply := centrifuge.SubscribeReply{} - handler, err := glive.GetChannelHandler(e.Channel) + handler, err := g.GetChannelHandler(e.Channel) if err != nil { return reply, err } @@ -141,7 +158,7 @@ func InitializeBroker() (*GrafanaLive, error) { // Called when something is written to the websocket node.OnPublish(func(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { reply := centrifuge.PublishReply{} - handler, err := glive.GetChannelHandler(e.Channel) + handler, err := g.GetChannelHandler(e.Channel) if err != nil { return reply, err } @@ -158,7 +175,7 @@ func InitializeBroker() (*GrafanaLive, error) { // Run node. This method does not block. if err := node.Run(); err != nil { - return nil, err + return err } // SockJS will find the best protocol possible for the browser @@ -175,7 +192,7 @@ func InitializeBroker() (*GrafanaLive, error) { WriteBufferSize: 1024, }) - glive.WebsocketHandler = func(ctx *models.ReqContext) { + g.WebsocketHandler = func(ctx *models.ReqContext) { user := ctx.SignedInUser if user == nil { ctx.Resp.WriteHeader(401) @@ -223,7 +240,10 @@ func InitializeBroker() (*GrafanaLive, error) { // Unknown path ctx.Resp.WriteHeader(404) } - return glive, nil + + g.RouteRegister.Any("/live/*", g.WebsocketHandler) + + return nil } // GetChannelHandler gives threadsafe access to the channel @@ -280,6 +300,14 @@ func (g *GrafanaLive) GetChannelHandlerFactory(scope string, name string) (model } if scope == "plugin" { + // Temporary hack until we have a more generic solution later on + if name == "cloudwatch" { + return &cloudwatch.LogQueryRunnerSupplier{ + Publisher: g.Publish, + Service: g.LogsService, + }, nil + } + p, ok := plugins.Plugins[name] if ok { h := &PluginHandler{ @@ -299,6 +327,11 @@ func (g *GrafanaLive) Publish(channel string, data []byte) error { return err } +// IsEnabled returns true if the Grafana Live feature is enabled. +func (g *GrafanaLive) IsEnabled() bool { + return g.Cfg.IsLiveEnabled() +} + // Write to the standard log15 logger func handleLog(msg centrifuge.LogEntry) { arr := make([]interface{}, 0) diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 7e3d3b7a185..d0198150617 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -5,7 +5,6 @@ import ( "fmt" "regexp" "strings" - "sync" "time" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -27,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb" ) @@ -54,14 +54,30 @@ var plog = log.New("tsdb.cloudwatch") var aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) func init() { - tsdb.RegisterTsdbQueryEndpoint("cloudwatch", func(ds *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - return newExecutor(), nil + registry.Register(®istry.Descriptor{ + Name: "CloudWatchService", + InitPriority: registry.Low, + Instance: &CloudWatchService{}, }) } -func newExecutor() *cloudWatchExecutor { +type CloudWatchService struct { + LogsService *LogsService `inject:""` +} + +func (s *CloudWatchService) Init() error { + plog.Debug("initing") + + tsdb.RegisterTsdbQueryEndpoint("cloudwatch", func(ds *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + return newExecutor(s.LogsService), nil + }) + + return nil +} + +func newExecutor(logsService *LogsService) *cloudWatchExecutor { return &cloudWatchExecutor{ - logsClientsByRegion: map[string]cloudwatchlogsiface.CloudWatchLogsAPI{}, + logsService: logsService, } } @@ -69,10 +85,10 @@ func newExecutor() *cloudWatchExecutor { type cloudWatchExecutor struct { *models.DataSource - ec2Client ec2iface.EC2API - rgtaClient resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI - logsClientsByRegion map[string]cloudwatchlogsiface.CloudWatchLogsAPI - mtx sync.Mutex + ec2Client ec2iface.EC2API + rgtaClient resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI + + logsService *LogsService } func (e *cloudWatchExecutor) newSession(region string) (*session.Session, error) { @@ -187,20 +203,12 @@ func (e *cloudWatchExecutor) getCWClient(region string) (cloudwatchiface.CloudWa } func (e *cloudWatchExecutor) getCWLogsClient(region string) (cloudwatchlogsiface.CloudWatchLogsAPI, error) { - e.mtx.Lock() - defer e.mtx.Unlock() - - if logsClient, ok := e.logsClientsByRegion[region]; ok { - return logsClient, nil - } - sess, err := e.newSession(region) if err != nil { return nil, err } logsClient := newCWLogsClient(sess) - e.logsClientsByRegion[region] = logsClient return logsClient, nil } @@ -301,6 +309,8 @@ func (e *cloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc result, err = e.executeAnnotationQuery(ctx, queryContext) case "logAction": result, err = e.executeLogActions(ctx, queryContext) + case "liveLogAction": + result, err = e.executeLiveLogQuery(ctx, queryContext) case "timeSeriesQuery": fallthrough default: diff --git a/pkg/tsdb/cloudwatch/live.go b/pkg/tsdb/cloudwatch/live.go new file mode 100644 index 00000000000..cbe3eaa24d8 --- /dev/null +++ b/pkg/tsdb/cloudwatch/live.go @@ -0,0 +1,319 @@ +package cloudwatch + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go/service/servicequotas" + "github.com/aws/aws-sdk-go/service/servicequotas/servicequotasiface" + "github.com/centrifugal/centrifuge" + "github.com/google/uuid" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/util/retryer" + "golang.org/x/sync/errgroup" +) + +const defaultConcurrentQueries = 4 + +type LogQueryRunnerSupplier struct { + Publisher models.ChannelPublisher + Service *LogsService +} + +type logQueryRunner struct { + channelName string + publish models.ChannelPublisher + running map[string]bool + runningMu sync.Mutex + service *LogsService +} + +const ( + maxAttempts = 8 + minRetryDelay = 500 * time.Millisecond + maxRetryDelay = 30 * time.Second +) + +// GetHandlerForPath gets the channel handler for a certain path. +func (s *LogQueryRunnerSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) { + return &logQueryRunner{ + channelName: path, + publish: s.Publisher, + running: make(map[string]bool), + service: s.Service, + }, nil +} + +// GetChannelOptions gets channel options. +// It's called fast and often. +func (r *logQueryRunner) GetChannelOptions(id string) centrifuge.ChannelOptions { + return centrifuge.ChannelOptions{} +} + +// OnSubscribe publishes results from the corresponding CloudWatch Logs query to the provided channel +func (r *logQueryRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { + r.runningMu.Lock() + defer r.runningMu.Unlock() + + if _, ok := r.running[e.Channel]; ok { + return nil + } + + r.running[e.Channel] = true + go func() { + if err := r.publishResults(e.Channel); err != nil { + plog.Error(err.Error()) + } + }() + + return nil +} + +// OnPublish is called when an event is received from the websocket. +func (r *logQueryRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { + return nil, fmt.Errorf("can not publish") +} + +func (r *logQueryRunner) publishResults(channelName string) error { + defer func() { + r.service.DeleteResponseChannel(channelName) + r.runningMu.Lock() + delete(r.running, channelName) + r.runningMu.Unlock() + }() + + responseChannel, err := r.service.GetResponseChannel(channelName) + if err != nil { + return err + } + + for response := range responseChannel { + responseBytes, err := json.Marshal(response) + if err != nil { + return err + } + + if err := r.publish(channelName, responseBytes); err != nil { + return err + } + } + + return nil +} + +// executeLiveLogQuery executes a CloudWatch Logs query with live updates over WebSocket. +// A WebSocket channel is created, which goroutines send responses over. +func (e *cloudWatchExecutor) executeLiveLogQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { + responseChannelName := uuid.New().String() + responseChannel := make(chan *tsdb.Response) + if err := e.logsService.AddResponseChannel("plugin/cloudwatch/"+responseChannelName, responseChannel); err != nil { + close(responseChannel) + return nil, err + } + + go e.sendLiveQueriesToChannel(queryContext, responseChannel) + + response := &tsdb.Response{ + Results: map[string]*tsdb.QueryResult{ + "A": { + RefId: "A", + Meta: simplejson.NewFromAny(map[string]interface{}{ + "channelName": responseChannelName, + }), + }, + }, + } + + return response, nil +} + +func (e *cloudWatchExecutor) sendLiveQueriesToChannel(queryContext *tsdb.TsdbQuery, responseChannel chan *tsdb.Response) { + defer close(responseChannel) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + eg, ectx := errgroup.WithContext(ctx) + + for _, query := range queryContext.Queries { + query := query + eg.Go(func() error { + return e.startLiveQuery(ectx, responseChannel, query, queryContext.TimeRange) + }) + } + + if err := eg.Wait(); err != nil { + plog.Error(err.Error()) + } +} + +func (e *cloudWatchExecutor) getQueue(queueKey string) (chan bool, error) { + e.logsService.queueLock.Lock() + defer e.logsService.queueLock.Unlock() + + if queue, ok := e.logsService.queues[queueKey]; ok { + return queue, nil + } + + concurrentQueriesQuota := e.fetchConcurrentQueriesQuota(queueKey) + + queueChannel := make(chan bool, concurrentQueriesQuota) + e.logsService.queues[queueKey] = queueChannel + + return queueChannel, nil +} + +func (e *cloudWatchExecutor) fetchConcurrentQueriesQuota(region string) int { + sess, err := e.newSession(region) + if err != nil { + plog.Warn("Could not get service quota client") + return defaultConcurrentQueries + } + + client := newQuotasClient(sess) + + concurrentQueriesQuota, err := client.GetServiceQuota(&servicequotas.GetServiceQuotaInput{ + ServiceCode: aws.String("logs"), + QuotaCode: aws.String("L-32C48FBB"), + }) + if err != nil { + plog.Warn("Could not get service quota") + return defaultConcurrentQueries + } + + if concurrentQueriesQuota != nil && concurrentQueriesQuota.Quota != nil && concurrentQueriesQuota.Quota.Value != nil { + return int(*concurrentQueriesQuota.Quota.Value) + } + + plog.Warn("Could not get service quota") + + defaultConcurrentQueriesQuota, err := client.GetAWSDefaultServiceQuota(&servicequotas.GetAWSDefaultServiceQuotaInput{ + ServiceCode: aws.String("logs"), + QuotaCode: aws.String("L-32C48FBB"), + }) + if err != nil { + plog.Warn("Could not get default service quota") + return defaultConcurrentQueries + } + + if defaultConcurrentQueriesQuota != nil && defaultConcurrentQueriesQuota.Quota != nil && defaultConcurrentQueriesQuota.Quota.Value != nil { + return int(*defaultConcurrentQueriesQuota.Quota.Value) + } + + plog.Warn("Could not get default service quota") + return defaultConcurrentQueries +} + +func (e *cloudWatchExecutor) startLiveQuery(ctx context.Context, responseChannel chan *tsdb.Response, query *tsdb.Query, timeRange *tsdb.TimeRange) error { + defaultRegion := e.DataSource.JsonData.Get("defaultRegion").MustString() + parameters := query.Model + region := parameters.Get("region").MustString(defaultRegion) + logsClient, err := e.getCWLogsClient(region) + if err != nil { + return err + } + + queue, err := e.getQueue(fmt.Sprintf("%s-%d", region, e.DataSource.Id)) + if err != nil { + return err + } + + // Wait until there are no more active workers than the concurrent queries quota + queue <- true + defer func() { <-queue }() + + startQueryOutput, err := e.executeStartQuery(ctx, logsClient, parameters, timeRange) + if err != nil { + return err + } + + queryResultsInput := &cloudwatchlogs.GetQueryResultsInput{ + QueryId: startQueryOutput.QueryId, + } + + recordsMatched := 0.0 + return retryer.Retry(func() (retryer.RetrySignal, error) { + getQueryResultsOutput, err := logsClient.GetQueryResultsWithContext(ctx, queryResultsInput) + if err != nil { + return retryer.FuncError, err + } + + retryNeeded := *getQueryResultsOutput.Statistics.RecordsMatched <= recordsMatched + recordsMatched = *getQueryResultsOutput.Statistics.RecordsMatched + + dataFrame, err := logsResultsToDataframes(getQueryResultsOutput) + if err != nil { + return retryer.FuncError, err + } + + dataFrame.Name = query.RefId + dataFrame.RefID = query.RefId + var dataFrames data.Frames + + // When a query of the form "stats ... by ..." is made, we want to return + // one series per group defined in the query, but due to the format + // the query response is in, there does not seem to be a way to tell + // by the response alone if/how the results should be grouped. + // Because of this, if the frontend sees that a "stats ... by ..." query is being made + // the "statsGroups" parameter is sent along with the query to the backend so that we + // can correctly group the CloudWatch logs response. + statsGroups := parameters.Get("statsGroups").MustStringArray() + if len(statsGroups) > 0 && len(dataFrame.Fields) > 0 { + groupedFrames, err := groupResults(dataFrame, statsGroups) + if err != nil { + return retryer.FuncError, err + } + + dataFrames = groupedFrames + } else { + if dataFrame.Meta != nil { + dataFrame.Meta.PreferredVisualization = "logs" + } else { + dataFrame.Meta = &data.FrameMeta{ + PreferredVisualization: "logs", + } + } + + dataFrames = data.Frames{dataFrame} + } + + responseChannel <- &tsdb.Response{ + Results: map[string]*tsdb.QueryResult{ + query.RefId: { + RefId: query.RefId, + Dataframes: tsdb.NewDecodedDataFrames(dataFrames), + }, + }, + } + + if isTerminated(*getQueryResultsOutput.Status) { + return retryer.FuncComplete, nil + } else if retryNeeded { + return retryer.FuncFailure, nil + } + + return retryer.FuncSuccess, nil + }, maxAttempts, minRetryDelay, maxRetryDelay) +} + +// Service quotas client factory. +// +// Stubbable by tests. +var newQuotasClient = func(sess *session.Session) servicequotasiface.ServiceQuotasAPI { + client := servicequotas.New(sess) + client.Handlers.Send.PushFront(func(r *request.Request) { + r.HTTPRequest.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion)) + }) + + return client +} diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 223dfa1721a..c4017b2b1b9 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -47,7 +47,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) { }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -100,7 +100,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) { }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -170,7 +170,7 @@ func TestQuery_GetLogGroupFields(t *testing.T) { const refID = "A" - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -249,7 +249,7 @@ func TestQuery_StartQuery(t *testing.T) { To: "1584700643000", } - executor := newExecutor() + executor := newExecutor(nil) _, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ TimeRange: timeRange, Queries: []*tsdb.Query{ @@ -295,7 +295,7 @@ func TestQuery_StartQuery(t *testing.T) { To: "1584873443000", } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ TimeRange: timeRange, Queries: []*tsdb.Query{ @@ -371,7 +371,7 @@ func TestQuery_StopQuery(t *testing.T) { To: "1584700643000", } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ TimeRange: timeRange, Queries: []*tsdb.Query{ @@ -458,7 +458,7 @@ func TestQuery_GetQueryResults(t *testing.T) { }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { diff --git a/pkg/tsdb/cloudwatch/logs.go b/pkg/tsdb/cloudwatch/logs.go new file mode 100644 index 00000000000..960f1fc8394 --- /dev/null +++ b/pkg/tsdb/cloudwatch/logs.go @@ -0,0 +1,63 @@ +package cloudwatch + +import ( + "fmt" + "sync" + + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/tsdb" +) + +func init() { + registry.RegisterService(&LogsService{}) +} + +// LogsService provides methods for querying CloudWatch Logs. +type LogsService struct { + channelMu sync.Mutex + responseChannels map[string]chan *tsdb.Response + queues map[string](chan bool) + queueLock sync.Mutex +} + +// Init is called by the DI framework to initialize the instance. +func (s *LogsService) Init() error { + s.responseChannels = make(map[string]chan *tsdb.Response) + s.queues = make(map[string](chan bool)) + return nil +} + +func (s *LogsService) AddResponseChannel(name string, channel chan *tsdb.Response) error { + s.channelMu.Lock() + defer s.channelMu.Unlock() + + if _, ok := s.responseChannels[name]; ok { + return fmt.Errorf("channel with name '%s' already exists", name) + } + + s.responseChannels[name] = channel + return nil +} + +func (s *LogsService) GetResponseChannel(name string) (chan *tsdb.Response, error) { + s.channelMu.Lock() + defer s.channelMu.Unlock() + + if responseChannel, ok := s.responseChannels[name]; ok { + return responseChannel, nil + } + + return nil, fmt.Errorf("channel with name '%s' not found", name) +} + +func (s *LogsService) DeleteResponseChannel(name string) { + s.channelMu.Lock() + defer s.channelMu.Unlock() + + if _, ok := s.responseChannels[name]; ok { + delete(s.responseChannels, name) + return + } + + plog.Warn("Channel with name '" + name + "' not found") +} diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 0edb7933940..4dd2146ca5f 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -44,7 +44,7 @@ func TestQuery_Metrics(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -101,7 +101,7 @@ func TestQuery_Metrics(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -163,7 +163,7 @@ func TestQuery_Regions(t *testing.T) { cli = fakeEC2Client{ regions: []string{regionName}, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -245,7 +245,7 @@ func TestQuery_InstanceAttributes(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -348,7 +348,7 @@ func TestQuery_EBSVolumeIDs(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -448,7 +448,7 @@ func TestQuery_ResourceARNs(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { diff --git a/pkg/tsdb/cloudwatch/query_transformer_test.go b/pkg/tsdb/cloudwatch/query_transformer_test.go index 7bbd6db93a2..7fd9054a57f 100644 --- a/pkg/tsdb/cloudwatch/query_transformer_test.go +++ b/pkg/tsdb/cloudwatch/query_transformer_test.go @@ -9,7 +9,7 @@ import ( ) func TestQueryTransformer(t *testing.T) { - executor := newExecutor() + executor := newExecutor(nil) t.Run("One cloudwatchQuery is generated when its request query has one stat", func(t *testing.T) { requestQueries := []*requestQuery{ { diff --git a/pkg/tsdb/cloudwatch/session_test.go b/pkg/tsdb/cloudwatch/session_test.go index 8dac32b1bad..24294c5e11f 100644 --- a/pkg/tsdb/cloudwatch/session_test.go +++ b/pkg/tsdb/cloudwatch/session_test.go @@ -57,7 +57,7 @@ func TestNewSession_AssumeRole(t *testing.T) { const roleARN = "test" - e := newExecutor() + e := newExecutor(nil) e.DataSource = fakeDataSource(fakeDataSourceCfg{ assumeRoleARN: roleARN, }) @@ -84,7 +84,7 @@ func TestNewSession_AssumeRole(t *testing.T) { const roleARN = "test" const externalID = "external" - e := newExecutor() + e := newExecutor(nil) e.DataSource = fakeDataSource(fakeDataSourceCfg{ assumeRoleARN: roleARN, externalID: externalID, diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index df087d5568e..8afdf3bfa11 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -9,7 +9,7 @@ import ( ) func TestTimeSeriesQuery(t *testing.T) { - executor := newExecutor() + executor := newExecutor(nil) t.Run("End time before start time should result in error", func(t *testing.T) { _, err := executor.executeTimeSeriesQuery(context.TODO(), &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}) diff --git a/pkg/util/retryer/retryer.go b/pkg/util/retryer/retryer.go new file mode 100644 index 00000000000..8055f06841f --- /dev/null +++ b/pkg/util/retryer/retryer.go @@ -0,0 +1,56 @@ +package retryer + +import ( + "time" +) + +type RetrySignal = int + +const ( + FuncSuccess RetrySignal = iota + FuncFailure + FuncComplete + FuncError +) + +// Retry retries the provided function using exponential backoff, starting with `minDelay` between attempts, and increasing to +// `maxDelay` after each failure. Stops when the provided function returns `FuncComplete`, or `maxRetries` is reached. +func Retry(body func() (RetrySignal, error), maxRetries int, minDelay time.Duration, maxDelay time.Duration) error { + currentDelay := minDelay + ticker := time.NewTicker(currentDelay) + defer ticker.Stop() + + retries := 0 + for range ticker.C { + response, err := body() + if err != nil { + return err + } + + switch response { + case FuncSuccess: + currentDelay = minDelay + ticker.Reset(currentDelay) + retries = 0 + case FuncFailure: + currentDelay = minDuration(currentDelay*2, maxDelay) + ticker.Reset(currentDelay) + retries++ + case FuncComplete: + return nil + } + + if retries >= maxRetries { + return nil + } + } + + return nil +} + +func minDuration(a time.Duration, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} diff --git a/pkg/util/retryer/retryer_test.go b/pkg/util/retryer/retryer_test.go new file mode 100644 index 00000000000..575762f602b --- /dev/null +++ b/pkg/util/retryer/retryer_test.go @@ -0,0 +1,22 @@ +package retryer + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMaxRetries(t *testing.T) { + retryVal := 0 + + err := Retry(func() (RetrySignal, error) { + retryVal++ + return FuncFailure, nil + }, 8, 100*time.Millisecond, 100*time.Millisecond) + if err != nil { + assert.FailNow(t, "Error while retrying function") + } + + assert.Equal(t, 8, retryVal) +} diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx index 0d805d98354..9fca877457a 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx @@ -33,7 +33,7 @@ const setup = () => { templateSrv.init([variable]); const datasource = new CloudWatchDatasource(instanceSettings, templateSrv as any, {} as any); - datasource.metricFindQuery = async () => [{ value: 'test', label: 'test' }]; + datasource.metricFindQuery = async () => [{ value: 'test', label: 'test', text: 'test' }]; const props: Props = { query: { diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 3f617902a6c..a82dfd1c120 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -18,8 +18,11 @@ import { TimeRange, rangeUtil, DataQueryErrorType, + LiveChannelScope, + LiveChannelEvent, + LiveChannelMessageEvent, } from '@grafana/data'; -import { getBackendSrv, toDataQueryResponse } from '@grafana/runtime'; +import { getBackendSrv, getGrafanaLiveSrv, toDataQueryResponse } from '@grafana/runtime'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { ThrottlingErrorMessage } from './components/ThrottlingErrorMessage'; @@ -41,13 +44,26 @@ import { isCloudWatchLogsQuery, } from './types'; import { from, Observable, of, merge, zip } from 'rxjs'; -import { catchError, finalize, map, mergeMap, tap, concatMap, scan, share, repeat, takeWhile } from 'rxjs/operators'; +import { + catchError, + finalize, + map, + mergeMap, + tap, + concatMap, + scan, + share, + repeat, + takeWhile, + filter, +} from 'rxjs/operators'; import { CloudWatchLanguageProvider } from './language_provider'; import { VariableWithMultiSupport } from 'app/features/variables/types'; import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import { AwsUrl, encodeUrl } from './aws_url'; import { increasingInterval } from './utils/rxjs/increasingInterval'; +import config from 'app/core/config'; const TSDB_QUERY_ENDPOINT = '/api/tsdb/query'; @@ -72,30 +88,32 @@ const displayCustomError = (title: string, message: string) => export const MAX_ATTEMPTS = 5; export class CloudWatchDatasource extends DataSourceApi { - type: any; proxyUrl: any; defaultRegion: any; - standardStatistics: any; datasourceName: string; - debouncedAlert: (datasourceName: string, region: string) => void; - debouncedCustomAlert: (title: string, message: string) => void; - logQueries: Record; languageProvider: CloudWatchLanguageProvider; + type = 'cloudwatch'; + standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount']; + debouncedAlert: (datasourceName: string, region: string) => void = memoizedDebounce( + displayAlert, + AppNotificationTimeout.Error + ); + debouncedCustomAlert: (title: string, message: string) => void = memoizedDebounce( + displayCustomError, + AppNotificationTimeout.Error + ); + logQueries: Record = {}; + constructor( instanceSettings: DataSourceInstanceSettings, private readonly templateSrv: TemplateSrv = getTemplateSrv(), private readonly timeSrv: TimeSrv = getTimeSrv() ) { super(instanceSettings); - this.type = 'cloudwatch'; this.proxyUrl = instanceSettings.url; this.defaultRegion = instanceSettings.jsonData.defaultRegion; this.datasourceName = instanceSettings.name; - this.standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount']; - this.debouncedAlert = memoizedDebounce(displayAlert, AppNotificationTimeout.Error); - this.debouncedCustomAlert = memoizedDebounce(displayCustomError, AppNotificationTimeout.Error); - this.logQueries = {}; this.languageProvider = new CloudWatchLanguageProvider(this); } @@ -108,7 +126,11 @@ export class CloudWatchDatasource extends DataSourceApi> = []; if (logQueries.length > 0) { - dataQueryResponses.push(this.handleLogQueries(logQueries, options)); + if (config.featureToggles.live) { + dataQueryResponses.push(this.handleLiveLogQueries(logQueries, options)); + } else { + dataQueryResponses.push(this.handleLogQueries(logQueries, options)); + } } if (metricsQueries.length > 0) { @@ -126,6 +148,75 @@ export class CloudWatchDatasource extends DataSourceApi + ): Observable => { + const validLogQueries = logQueries.filter(item => item.logGroupNames?.length); + if (logQueries.length > validLogQueries.length) { + return of({ data: [], error: { message: 'Log group is required' } }); + } + + // No valid targets, return the empty result to save a round trip. + if (_.isEmpty(validLogQueries)) { + return of({ data: [], state: LoadingState.Done }); + } + + const queryParams = validLogQueries.map((target: CloudWatchLogsQuery) => ({ + intervalMs: 1, // dummy + maxDataPoints: 1, // dummy + datasourceId: this.id, + queryString: this.replace(target.expression, options.scopedVars, true), + refId: target.refId, + logGroupNames: target.logGroupNames?.map(logGroup => + this.replace(logGroup, options.scopedVars, true, 'log groups') + ), + statsGroups: target.statsGroups, + region: this.getActualRegion(this.replace(target.region, options.scopedVars, true, 'region')), + type: 'liveLogAction', + })); + + const range = this.timeSrv.timeRange(); + + const requestParams = { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + queries: queryParams, + }; + + return from(this.awsRequest(TSDB_QUERY_ENDPOINT, requestParams)).pipe( + mergeMap((response: TSDBResponse) => { + const channelName: string = response.results['A'].meta.channelName; + const channel = getGrafanaLiveSrv().getChannel({ + scope: LiveChannelScope.Plugin, + namespace: 'cloudwatch', + path: channelName, + }); + return channel.getStream(); + }), + filter((e: LiveChannelEvent) => e.type === 'message'), + map(({ message }: LiveChannelMessageEvent) => { + const dataQueryResponse = toDataQueryResponse({ + data: message, + }); + dataQueryResponse.state = dataQueryResponse.data.every(dataFrame => + statusIsTerminated(dataFrame.meta?.custom?.['Status']) + ) + ? LoadingState.Done + : LoadingState.Loading; + dataQueryResponse.key = message.results[Object.keys(message.results)[0]].refId; + return this.addDataLinksToLogsResponse(dataQueryResponse, options); + }), + catchError(err => { + if (err.data?.error) { + throw err.data.error; + } + + throw err; + }) + ); + }; + handleLogQueries = ( logQueries: CloudWatchLogsQuery[], options: DataQueryRequest @@ -1021,3 +1112,12 @@ function parseLogGroupName(logIdentifier: string): string { const colonIndex = logIdentifier.lastIndexOf(':'); return logIdentifier.substr(colonIndex + 1); } + +function statusIsTerminated(status: string | CloudWatchLogsQueryStatus) { + return [ + CloudWatchLogsQueryStatus.Complete, + CloudWatchLogsQueryStatus.Cancelled, + CloudWatchLogsQueryStatus.Failed, + CloudWatchLogsQueryStatus.Timeout, + ].includes(status as CloudWatchLogsQueryStatus); +} diff --git a/public/app/plugins/datasource/cloudwatch/module.tsx b/public/app/plugins/datasource/cloudwatch/module.tsx index e40f0415e09..81a738d4df1 100644 --- a/public/app/plugins/datasource/cloudwatch/module.tsx +++ b/public/app/plugins/datasource/cloudwatch/module.tsx @@ -16,4 +16,10 @@ export const plugin = new DataSourcePlugin ({ + path, + }), + getSupportedPaths: () => [], + }); diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index 14217d36b8f..d756dff63fa 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -30,6 +30,7 @@ export enum CloudWatchLogsQueryStatus { Complete = 'Complete', Failed = 'Failed', Cancelled = 'Cancelled', + Timeout = 'Timeout', } export interface CloudWatchLogsQuery extends DataQuery { From 0684d89c0e3bfdc67590bb60c3e229835cf77c54 Mon Sep 17 00:00:00 2001 From: Arve Knudsen Date: Wed, 28 Oct 2020 11:00:33 +0100 Subject: [PATCH 015/132] CI: Add GCC to Windows Docker image (#28562) * CI: Add GCC to Windows Docker image Signed-off-by: Arve Knudsen * CI: Upgrade golangci-lint in Windows Docker image Signed-off-by: Arve Knudsen --- scripts/build/ci-build-windows/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/build/ci-build-windows/Dockerfile b/scripts/build/ci-build-windows/Dockerfile index 32314c08794..9cb29deb0aa 100644 --- a/scripts/build/ci-build-windows/Dockerfile +++ b/scripts/build/ci-build-windows/Dockerfile @@ -7,8 +7,8 @@ RUN powershell Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force RUN powershell Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh') # Scoop first of all needs git to update itself # Run Scoop under PowerShell since it can otherwise fail -RUN powershell -Command scoop install git@2.28.0.windows.1 -RUN powershell -Command scoop install go@1.15.2 unzip@6.00 +RUN powershell -Command scoop install git@2.29.1.windows.1 +RUN powershell -Command scoop install go@1.15.3 unzip@6.00 gcc@9.3.0-2 # Install diffutils, in case we need them RUN powershell (New-Object Net.WebClient).DownloadFile(\ @@ -21,12 +21,12 @@ RUN mkdir -p "C:\Program Files (x86)\GnuWin32" RUN cd "C:\Program Files (x86)\GnuWin32" && unzip C:\App\diffutils-dep.zip && unzip C:\App\diffutils-bin.zip RUN powershell (New-Object Net.WebClient).DownloadFile(\ - \"https://github.com/golangci/golangci-lint/releases/download/v1.31.0/golangci-lint-1.31.0-windows-amd64.zip\", \ + \"https://github.com/golangci/golangci-lint/releases/download/v1.32.0/golangci-lint-1.32.0-windows-amd64.zip\", \ \"golangci-lint.zip\") RUN powershell (Get-FileHash golangci-lint.zip -Algorithm SHA256).Hash -eq \ - \"6CE6B1D3207A63256D82FBBAC80BB9E85D7705EC1A408F005DFE324457C54966\" + \"97a69c2a153cd4285b7000b327aa6e77b694534e7463cbd1b77481c22b6113cf\" RUN unzip golangci-lint.zip -RUN powershell -Command mv golangci-lint-1.31.0-windows-amd64\golangci-lint.exe . +RUN powershell -Command mv golangci-lint-1.32.0-windows-amd64\golangci-lint.exe . RUN powershell -Command scoop cache rm '*' From aabd3bdf7282e779e7359099f5f5a6ab22a7c8cd Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 28 Oct 2020 11:27:15 +0100 Subject: [PATCH 016/132] Docs: Additional 7.3 upgrade notes (#28592) Adds a couple upgrade notes for v7.3 regarding database migrations for user invites and snapshots. Co-authored-by: Will Browne --- docs/sources/installation/upgrading.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 9ed1784aea7..244e565588e 100755 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -297,4 +297,21 @@ The other authentication methods, _Access & secret key_ and _Credentials file_, For more information and details, please refer to [Using AWS CloudWatch in Grafana]({{< relref "../datasources/cloudwatch.md#authentication" >}}). - \ No newline at end of file +### User invites database migration + +The database table _temp\_user_, that tracks user invites, is subject to a database migration that changes the data type of the _created_ and _updated_ columns: + +| Database | Old data type | New data type | +| -------- | ------------- | ------------- | +| Sqlite | DATETIME | INTEGER | +| MySQL | DATETIME | INT | +| Postgres | TIMESTAMP | INTEGER | + +> Please note that if downgrading Grafana to an earlier version, you have to manually change the data type of the _created_ and _updated_ columns back to _old data type_ , otherwise the user invite feature doesn't function as expected. + +### Snapshots database migration + +The database table _dashboard\_snapshot_, that stores dashboard snapshots, adds a new column _dashboard\_encrypted_ for storing an encrypted snapshot. +NOTE: Only snapshots created on Grafana 7.3 or later will use this column to store snapshot data as encrypted. Snapshots created before this version will be unaffected and remain unencrypted. + + From cb965ce17740b567e1f7ea946778348cc77bfeff Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 28 Oct 2020 11:27:48 +0100 Subject: [PATCH 017/132] Docs: Data source provisioning and sigV4 (#28593) Document the sigv4 properties for data source provisioning. --- docs/sources/administration/provisioning.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index a5f3e0c6800..24ad4741d8b 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -157,6 +157,11 @@ Since not all datasources have the same configuration settings we only have the | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | | logMessageField | string | Elasticsearch | Which field should be used as the log message | | logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message | +| sigV4AuthType | string | Elasticsearch | SigV4 auth provider. default/credentials/keys | +| sigV4ExternalId | string | Elasticsearch | Optional SigV4 External ID | +| sigV4AssumeRoleArn | string | Elasticsearch | Optional SigV4 ARN role to assume | +| sigV4Region | string | Elasticsearch | SigV4 AWS region | +| sigV4Profile | string | Elasticsearch | Optional SigV4 credentials profile | | authType | string | Cloudwatch | Auth provider. default/credentials/keys | | externalId | string | Cloudwatch | Optional External ID | | assumeRoleArn | string | Cloudwatch | Optional ARN role to assume | @@ -191,6 +196,8 @@ Secure json data is a map of settings that will be encrypted with [secret key]({ | basicAuthPassword | string | _All_ | password for basic authentication | | accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch | | secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch | +| sigV4AccessKey | string | Elasticsearch | SigV4 access key. Required when using keys auth provider | +| sigV4SecretKey | string | Elasticsearch | SigV4 secret key. Required when using keys auth provider | #### Custom HTTP headers for datasources From d61e1e7b23942278805d264566dba4362e42a2bf Mon Sep 17 00:00:00 2001 From: Chunlin Yang Date: Wed, 28 Oct 2020 19:16:23 +0800 Subject: [PATCH 018/132] Dashboard: Allow add panel for viewers_can_edit (#28570) Signed-off-by: clyang82 --- public/app/features/dashboard/components/DashNav/DashNav.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index eeacb49458e..4023fad0ea0 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -217,12 +217,12 @@ class DashNav extends PureComponent { renderRightActionsButton() { const { dashboard, onAddPanel } = this.props; - const { canSave, showSettings } = dashboard.meta; + const { canEdit, showSettings } = dashboard.meta; const { snapshot } = dashboard; const snapshotUrl = snapshot && snapshot.originalUrl; const buttons: ReactNode[] = []; - if (canSave) { + if (canEdit) { buttons.push( Date: Wed, 28 Oct 2020 12:57:58 +0100 Subject: [PATCH 019/132] Live: updated the reference to use lazy loaded Monaco in code editor. (#28597) --- public/app/plugins/panel/live/LivePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/panel/live/LivePanel.tsx b/public/app/plugins/panel/live/LivePanel.tsx index 0e358275367..2567140735a 100755 --- a/public/app/plugins/panel/live/LivePanel.tsx +++ b/public/app/plugins/panel/live/LivePanel.tsx @@ -19,7 +19,7 @@ import { TablePanel } from '../table/TablePanel'; import { LivePanelOptions, MessageDisplayMode } from './types'; import { config, getGrafanaLiveSrv, MeasurementCollector } from '@grafana/runtime'; import { css, cx } from 'emotion'; -import CodeEditor from '@grafana/ui/src/components/Monaco/CodeEditor'; +import { CodeEditor } from '@grafana/ui'; interface Props extends PanelProps {} From 019173eb799726fac6282b96508fcdd4b6ca4c96 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 28 Oct 2020 14:49:38 +0100 Subject: [PATCH 020/132] Update uPlot to 1.2.2 and align timestamps config with new uPLot API (#28569) --- packages/grafana-ui/package.json | 2 +- .../src/components/GraphNG/GraphNG.tsx | 56 ++++++++----------- yarn.lock | 8 +-- 3 files changed, 28 insertions(+), 38 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 07ac5a3e8cd..391bf342a51 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -70,7 +70,7 @@ "react-transition-group": "4.3.0", "slate": "0.47.8", "tinycolor2": "1.4.1", - "uplot": "1.1.2" + "uplot": "1.2.2" }, "devDependencies": { "@rollup/plugin-commonjs": "11.0.2", diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 615761e0d54..108e16ba018 100644 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -15,47 +15,37 @@ import { UPlotChart } from '../uPlot/Plot'; import { AxisSide, GraphCustomFieldConfig, PlotProps } from '../uPlot/types'; import { useTheme } from '../../themes'; +const _ = null; + const timeStampsConfig = [ - [3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'], - [3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'], - [ - 3600 * 24, - `{${timeFormatToTemplate(systemDateFormats.interval.day)}`, - 7, - `${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`, - ], + // tick incr default year month day hour min sec mode + [3600 * 24 * 365, '{YYYY}', _, _, _, _, _, _, 1], + [3600 * 24 * 28, `${timeFormatToTemplate(systemDateFormats.interval.month)}`, _, _, _, _, _, _, 1], + [3600 * 24, `${timeFormatToTemplate(systemDateFormats.interval.day)}`, `\n{YYYY}`, _, _, _, _, _, 1], [ 3600, - `{${timeFormatToTemplate(systemDateFormats.interval.minute)}`, - 4, - `${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate( - systemDateFormats.interval.day - )}`, + `${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + _, + _, + `\n${timeFormatToTemplate(systemDateFormats.interval.day)}`, + _, + _, + _, + 1, ], [ 60, - `{${timeFormatToTemplate(systemDateFormats.interval.second)}`, - 4, - `${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate( - systemDateFormats.interval.day - )}`, - ], - [ + `${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + _, + _, + `\n${timeFormatToTemplate(systemDateFormats.interval.day)}`, + _, + _, + _, 1, - `:{ss}`, - 2, - `:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( - systemDateFormats.interval.minute - )}`, - ], - [ - 1e-3, - ':{ss}.{fff}', - 2, - `:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( - systemDateFormats.interval.minute - )}`, ], + [1, ':{ss}', _, _, _, _, `\n ${timeFormatToTemplate(systemDateFormats.interval.minute)}`, _, 1], + [1e-3, ':{ss}.{fff}', _, _, _, _, `\n ${timeFormatToTemplate(systemDateFormats.interval.minute)}`, _, 1], ]; const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); diff --git a/yarn.lock b/yarn.lock index baded1d2d07..f352748d387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26813,10 +26813,10 @@ update-notifier@^2.5.0: semver-diff "^2.0.0" xdg-basedir "^3.0.0" -uplot@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.1.2.tgz#ccdbe0987e7615d197e1dba77946a1655a823c31" - integrity sha512-CpQmMdafoMRR+zRSpfpMXs5mKvqgYFakcCyt7nOfh+pPeZfbxNMcCq9JFeXJcKEaWjrR6JSIiEZ01A4iFHztTQ== +uplot@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.2.2.tgz#b8876ab55c8a76fff81673b4b48fa5f76c4d9d2b" + integrity sha512-FiUCvD0QB+y0YGGtzTYhvaGktsddxiIFMSRScEsd97aasfnAGhIvs6aShbaB6/TZpKa6X1qVzFWuNgwnzaWBcg== upper-case-first@^1.1.0, upper-case-first@^1.1.2: version "1.1.2" From 1bff2fdeea613fdcbe7b6dd0842d781550b859d8 Mon Sep 17 00:00:00 2001 From: Kyle Brandt Date: Wed, 28 Oct 2020 09:56:34 -0400 Subject: [PATCH 021/132] changelog: update for 7.3.0 (#28602) --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f558ff0aed9..5eb406e1a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 7.3.0 (2020-10-28) + + ### Features / Enhancements + * **AzureMonitor**: Support decimal (as float64) type in analytics/logs. [#28480](https://github.com/grafana/grafana/pull/28480), [@kylebrandt](https://github.com/kylebrandt) + * **Plugins signing**: UI information. [#28469](https://github.com/grafana/grafana/pull/28469), [@dprokop](https://github.com/dprokop) + * **Short URL**: Update last seen at when visiting a short URL. [#28565](https://github.com/grafana/grafana/pull/28565), [@marefr](https://github.com/marefr) + + ### Bug Fixes + * **Alerting**: Log warnings for obsolete notifiers when extracting alerts and remove frequent error log messages. [#28162](https://github.com/grafana/grafana/pull/28162), [@papagian](https://github.com/papagian) + * **Auth**: Fix SigV4 request verification step for Amazon Elasticsearch Service. [#28481](https://github.com/grafana/grafana/pull/28481), [@wbrowne](https://github.com/wbrowne) + * **Auth**: Should redirect to login when anonymous enabled and URL with different org than anonymous specified. [#28158](https://github.com/grafana/grafana/pull/28158), [@marefr](https://github.com/marefr) + * **Elasticsearch**: Fix handling of errors when testing data source. [#28498](https://github.com/grafana/grafana/pull/28498), [@marefr](https://github.com/marefr) + * **Graphite**: Fix default version to be 1.1. [#28471](https://github.com/grafana/grafana/pull/28471), [@ivanahuckova](https://github.com/ivanahuckova) + * **StatPanel**: Fixes BizChart error max: yyy should not be less than min zzz. [#28587](https://github.com/grafana/grafana/pull/28587), [@hugohaggmark](https://github.com/hugohaggmark) + + # 7.3.0-beta2 (2020-10-22) ### Features / Enhancements From 85a04794aca05b9d591c60eafdd60a4745b622bc Mon Sep 17 00:00:00 2001 From: Isa Ozler <43741547+isaozlerfm@users.noreply.github.com> Date: Wed, 28 Oct 2020 15:00:31 +0100 Subject: [PATCH 022/132] Field config API: add slider editor (#28007) * Field config: implementation slider editor (#27592) * PR-28007 feedback fixed * Field config: implementation slider editor (#27592) * PR-28007 feedback fixed * processed review PR-28007 * Field config: implementation slider editor (#27592) * PR-28007 feedback fixed * Field config: implementation slider editor (#27592) * processed review PR-28007 * fixing leftover number[] bugs * RichHistoryQueriesTab.test fix + slider vertical feat fixed * fixed Slider.test.tsx expectation * Added @docs to prevent build-frontend-docs from failing Co-authored-by: Isa Ozler --- .../src/field/overrides/processors.ts | 6 + .../src/types/OptionsUIRegistryBuilder.ts | 11 +- .../src/utils/OptionsUIBuilders.ts | 21 +++ .../src/components/OptionsUI/slider.tsx | 27 +++ .../src/components/Slider/RangeSlider.mdx | 12 ++ .../components/Slider/RangeSlider.story.tsx | 30 +++ .../components/Slider/RangeSlider.test.tsx | 17 ++ .../src/components/Slider/RangeSlider.tsx | 53 ++++++ .../src/components/Slider/Slider.mdx | 8 +- .../src/components/Slider/Slider.story.tsx | 14 +- .../src/components/Slider/Slider.test.tsx | 8 +- .../src/components/Slider/Slider.tsx | 175 ++++++------------ .../src/components/Slider/styles.ts | 122 ++++++++++++ .../grafana-ui/src/components/Slider/types.ts | 29 +++ packages/grafana-ui/src/components/index.ts | 2 + .../grafana-ui/src/utils/standardEditors.tsx | 9 + .../RichHistoryQueriesTab.test.tsx | 4 +- .../RichHistory/RichHistoryQueriesTab.tsx | 4 +- public/app/plugins/panel/graph3/module.tsx | 52 ++---- 19 files changed, 423 insertions(+), 181 deletions(-) create mode 100644 packages/grafana-ui/src/components/OptionsUI/slider.tsx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.mdx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.tsx create mode 100644 packages/grafana-ui/src/components/Slider/styles.ts create mode 100644 packages/grafana-ui/src/components/Slider/types.ts diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts index a50da40520e..8152659c9ff 100644 --- a/packages/grafana-data/src/field/overrides/processors.ts +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -24,6 +24,12 @@ export const numberOverrideProcessor = ( return parseFloat(value); }; +export interface SliderFieldConfigSettings { + min: number; + max: number; + step?: number; +} + export interface DataLinksFieldConfigSettings {} export const dataLinksOverrideProcessor = ( diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts index b636a0f2029..291239469c3 100644 --- a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -1,6 +1,11 @@ import { ComponentType } from 'react'; import { RegistryItem, Registry } from '../utils/Registry'; -import { NumberFieldConfigSettings, SelectFieldConfigSettings, StringFieldConfigSettings } from '../field'; +import { + NumberFieldConfigSettings, + SliderFieldConfigSettings, + SelectFieldConfigSettings, + StringFieldConfigSettings, +} from '../field'; /** * Option editor registry item @@ -71,6 +76,10 @@ export interface OptionsUIRegistryBuilderAPI< config: OptionEditorConfig ): this; + addSliderInput?( + config: OptionEditorConfig + ): this; + addTextInput?( config: OptionEditorConfig ): this; diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index 98138e6b886..8fa49d92b3c 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -12,6 +12,7 @@ import { StandardEditorProps, StringFieldConfigSettings, NumberFieldConfigSettings, + SliderFieldConfigSettings, ColorFieldConfigSettings, identityOverrideProcessor, UnitFieldConfigSettings, @@ -39,6 +40,18 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder }); } + addSliderInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('slider').editor as any, + editor: standardEditorsRegistry.get('slider').editor as any, + process: numberOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : field => field.type === FieldType.number, + settings: config.settings || {}, + }); + } + addTextInput(config: FieldConfigEditorConfig) { return this.addCustomEditor({ ...config, @@ -136,6 +149,14 @@ export class PanelOptionsEditorBuilder extends OptionsUIRegistryBuilde }); } + addSliderInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('slider').editor as any, + }); + } + addTextInput(config: PanelOptionsEditorConfig) { return this.addCustomEditor({ ...config, diff --git a/packages/grafana-ui/src/components/OptionsUI/slider.tsx b/packages/grafana-ui/src/components/OptionsUI/slider.tsx new file mode 100644 index 00000000000..f5403f5b96a --- /dev/null +++ b/packages/grafana-ui/src/components/OptionsUI/slider.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react'; +import { FieldConfigEditorProps, SliderFieldConfigSettings } from '@grafana/data'; +import { Slider } from '../Slider/Slider'; + +export const SliderValueEditor: React.FC> = ({ + value, + onChange, + item, +}) => { + const { settings } = item; + const onValueAfterChange = useCallback( + (value?: number) => { + onChange(value); + }, + [onChange] + ); + const initialValue = typeof value === 'number' ? value : typeof value === 'string' ? +value : 0; + return ( + + ); +}; diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.mdx b/packages/grafana-ui/src/components/Slider/RangeSlider.mdx new file mode 100644 index 00000000000..2df51ccae28 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.mdx @@ -0,0 +1,12 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { RangeSliderProps } from './types'; + + + +# Range-slider + +The `Range-slider` component is an input element where users can manipulate two values on a one-dimensional axis. + +`Range-slider` can be implemented in horizontal or vertical orientation. You can set the default starting values for the slider with the `value` prop. + + diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx new file mode 100644 index 00000000000..f3bc6ba4e1a --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { RangeSlider } from '@grafana/ui'; +import { select, number, boolean } from '@storybook/addon-knobs'; + +export default { + title: 'Forms/Slider/Range', + component: RangeSlider, +}; + +const getKnobs = () => { + return { + min: number('min', 0), + max: number('max', 100), + step: boolean('enable step', false), + orientation: select('orientation', ['horizontal', 'vertical'], 'horizontal'), + reverse: boolean('reverse', false), + }; +}; + +const SliderWrapper = () => { + const { min, max, orientation, reverse, step } = getKnobs(); + const stepValue = step ? 10 : undefined; + return ( +
      + +
      + ); +}; + +export const basic = () => ; diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx new file mode 100644 index 00000000000..6011cbff161 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { RangeSlider } from './RangeSlider'; +import { RangeSliderProps } from './types'; +import { render } from '@testing-library/react'; + +const sliderProps: RangeSliderProps = { + min: 10, + max: 20, +}; + +describe('RangeSlider', () => { + it('renders without error', () => { + expect(() => { + render(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx new file mode 100644 index 00000000000..75b26a98b92 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx @@ -0,0 +1,53 @@ +import React, { FunctionComponent } from 'react'; +import { Range as RangeComponent, createSliderWithTooltip } from 'rc-slider'; +import { cx } from 'emotion'; +import { Global } from '@emotion/core'; +import { useTheme } from '../../themes/ThemeContext'; +import { getStyles } from './styles'; +import { RangeSliderProps } from './types'; + +/** + * @public + * + * RichHistoryQueriesTab uses this Range Component + */ +export const RangeSlider: FunctionComponent = ({ + min, + max, + onChange, + onAfterChange, + orientation = 'horizontal', + reverse, + step, + formatTooltipResult, + value, + tooltipAlwaysVisible = true, +}) => { + const isHorizontal = orientation === 'horizontal'; + const theme = useTheme(); + const styles = getStyles(theme, isHorizontal); + const RangeWithTooltip = createSliderWithTooltip(RangeComponent); + return ( +
      + {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} + + (formatTooltipResult ? formatTooltipResult(value) : value)} + onChange={onChange} + onAfterChange={onAfterChange} + vertical={!isHorizontal} + reverse={reverse} + /> +
      + ); +}; + +RangeSlider.displayName = 'Range'; diff --git a/packages/grafana-ui/src/components/Slider/Slider.mdx b/packages/grafana-ui/src/components/Slider/Slider.mdx index b1cf7820341..7a40f484391 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.mdx +++ b/packages/grafana-ui/src/components/Slider/Slider.mdx @@ -1,12 +1,12 @@ import { Meta, Props } from '@storybook/addon-docs/blocks'; -import { Slider } from './Slider'; +import { SliderProps } from './types'; # Slider -The `Slider` component is an input element where users can manipulate one or two values on a one-dimensional axis. +The `Slider` component is an input element where users can manipulate one value on a one-dimensional axis. -`Slider` can be implemented in horizontal or vertical orientation. You can set the default starting value(s) for the slider with the `value` prop. +`Slider` can be implemented in horizontal or vertical orientation. You can set the default starting value(s) for the slider with the `value` prop. - + diff --git a/packages/grafana-ui/src/components/Slider/Slider.story.tsx b/packages/grafana-ui/src/components/Slider/Slider.story.tsx index d659092e86b..931eba9f337 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.story.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.story.tsx @@ -13,24 +13,16 @@ const getKnobs = () => { max: number('max', 100), step: boolean('enable step', false), orientation: select('orientation', ['horizontal', 'vertical'], 'horizontal'), - reverse: boolean('reverse', true), - singleValue: boolean('single value', false), + reverse: boolean('reverse', false), }; }; const SliderWrapper = () => { - const { min, max, orientation, reverse, singleValue, step } = getKnobs(); + const { min, max, orientation, reverse, step } = getKnobs(); const stepValue = step ? 10 : undefined; return (
      - +
      ); }; diff --git a/packages/grafana-ui/src/components/Slider/Slider.test.tsx b/packages/grafana-ui/src/components/Slider/Slider.test.tsx index 277a905c15f..f688f546508 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.test.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Slider, Props } from './Slider'; +import { Slider } from './Slider'; +import { SliderProps } from './types'; import { mount } from 'enzyme'; -const sliderProps: Props = { +const sliderProps: SliderProps = { min: 10, max: 20, }; @@ -17,11 +18,10 @@ describe('Slider', () => { expect(wrapper.html()).toContain('aria-valuemin="10"'); expect(wrapper.html()).toContain('aria-valuemax="20"'); expect(wrapper.html()).toContain('aria-valuenow="10"'); - expect(wrapper.html()).toContain('aria-valuenow="20"'); }); it('renders correct contents with a value', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.html()).toContain('aria-valuenow="15"'); expect(wrapper.html()).not.toContain('aria-valuenow="20"'); expect(wrapper.html()).not.toContain('aria-valuenow="10"'); diff --git a/packages/grafana-ui/src/components/Slider/Slider.tsx b/packages/grafana-ui/src/components/Slider/Slider.tsx index f0095930b3b..de9058010bd 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.tsx @@ -1,104 +1,15 @@ -import React, { FunctionComponent } from 'react'; -import { Range, createSliderWithTooltip } from 'rc-slider'; -import { cx, css } from 'emotion'; -import { Global, css as cssCore } from '@emotion/core'; -import { stylesFactory } from '../../themes'; -import { GrafanaTheme } from '@grafana/data'; +import React, { useState, useCallback, ChangeEvent, FunctionComponent } from 'react'; +import SliderComponent from 'rc-slider'; +import { cx } from 'emotion'; +import { Global } from '@emotion/core'; import { useTheme } from '../../themes/ThemeContext'; -import { Orientation } from '../../types/orientation'; +import { getStyles } from './styles'; +import { SliderProps } from './types'; -export interface Props { - min: number; - max: number; - orientation?: Orientation; - /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ - value?: number[]; - reverse?: boolean; - step?: number; - tooltipAlwaysVisible?: boolean; - formatTooltipResult?: (value: number) => number | string; - onChange?: (values: number[]) => void; - onAfterChange?: (values: number[]) => void; -} - -const getStyles = stylesFactory((theme: GrafanaTheme, isHorizontal: boolean) => { - const trackColor = theme.isLight ? theme.palette.gray5 : theme.palette.dark6; - const container = isHorizontal - ? css` - width: 100%; - margin: ${theme.spacing.lg} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm}; - ` - : css` - height: 100%; - margin: ${theme.spacing.sm} ${theme.spacing.lg} ${theme.spacing.sm} ${theme.spacing.sm}; - `; - - return { - container, - slider: css` - .rc-slider-vertical .rc-slider-handle { - margin-top: -10px; - } - .rc-slider-handle { - border: solid 2px ${theme.palette.blue77}; - background-color: ${theme.palette.blue77}; - } - .rc-slider-handle:hover { - border-color: ${theme.palette.blue77}; - } - .rc-slider-handle:focus { - border-color: ${theme.palette.blue77}; - box-shadow: none; - } - .rc-slider-handle:active { - border-color: ${theme.palette.blue77}; - box-shadow: none; - } - .rc-slider-handle-click-focused:focus { - border-color: ${theme.palette.blue77}; - } - .rc-slider-dot-active { - border-color: ${theme.palette.blue77}; - } - .rc-slider-track { - background-color: ${theme.palette.blue77}; - } - .rc-slider-rail { - background-color: ${trackColor}; - border: 1px solid ${trackColor}; - } - `, - /** Global component from @emotion/core doesn't accept computed classname string returned from css from emotion. - * It accepts object containing the computed name and flattened styles returned from css from @emotion/core - * */ - tooltip: cssCore` - body { - .rc-slider-tooltip { - cursor: grab; - user-select: none; - z-index: ${theme.zIndex.tooltip}; - } - - .rc-slider-tooltip-inner { - color: ${theme.colors.text}; - background-color: transparent !important; - border-radius: 0; - box-shadow: none; - } - - .rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow { - display: none; - } - - .rc-slider-tooltip-placement-top { - padding: 0; - } - } - `, - }; -}); - -export const Slider: FunctionComponent = ({ +/** + * @public + */ +export const Slider: FunctionComponent = ({ min, max, onChange, @@ -106,33 +17,63 @@ export const Slider: FunctionComponent = ({ orientation = 'horizontal', reverse, step, - formatTooltipResult, value, - tooltipAlwaysVisible = true, }) => { const isHorizontal = orientation === 'horizontal'; const theme = useTheme(); const styles = getStyles(theme, isHorizontal); - const RangeWithTooltip = createSliderWithTooltip(Range); + const SliderWithTooltip = SliderComponent; + const [slidervalue, setSliderValue] = useState(value || min); + const onSliderChange = useCallback((v: number) => { + setSliderValue(v); + + if (onChange) { + onChange(v); + } + }, []); + const onSliderInputChange = useCallback((e: ChangeEvent) => { + let v = +e.target.value; + + v > max && (v = max); + v < min && (v = min); + + setSliderValue(v); + + if (onChange) { + onChange(v); + } + + if (onAfterChange) { + onAfterChange(v); + } + }, []); + const sliderInputClassNames = !isHorizontal ? [styles.sliderInputVertical] : []; + const sliderInputFieldClassNames = !isHorizontal ? [styles.sliderInputFieldVertical] : []; return (
      {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} - (formatTooltipResult ? formatTooltipResult(value) : value)} - onChange={onChange} - onAfterChange={onAfterChange} - vertical={!isHorizontal} - reverse={reverse} - /> +
      ); }; diff --git a/packages/grafana-ui/src/components/Slider/styles.ts b/packages/grafana-ui/src/components/Slider/styles.ts new file mode 100644 index 00000000000..b43399de633 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/styles.ts @@ -0,0 +1,122 @@ +import { stylesFactory } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; +import { focusCss } from '../../themes/mixins'; +import { css as cssCore } from '@emotion/core'; +import { css } from 'emotion'; + +export const getFocusStyle = (theme: GrafanaTheme) => css` + &:focus { + ${focusCss(theme)} + } +`; + +export const getStyles = stylesFactory((theme: GrafanaTheme, isHorizontal: boolean) => { + const trackColor = theme.isLight ? theme.palette.gray5 : theme.palette.dark6; + const container = isHorizontal + ? css` + width: 100%; + ` + : css` + height: 100%; + margin: ${theme.spacing.sm} ${theme.spacing.lg} ${theme.spacing.sm} ${theme.spacing.sm}; + `; + + return { + container, + slider: css` + .rc-slider { + display: flex; + flex-grow: 1; + margin-left: 7px; // half the size of the handle to align handle to the left on 0 value + } + .rc-slider-vertical .rc-slider-handle { + margin-top: -10px; + } + .rc-slider-handle { + border: solid 2px ${theme.palette.blue77}; + background-color: ${theme.palette.blue77}; + } + .rc-slider-handle:hover { + border-color: ${theme.palette.blue77}; + } + .rc-slider-handle:focus { + border-color: ${theme.palette.blue77}; + box-shadow: none; + } + .rc-slider-handle:active { + border-color: ${theme.palette.blue77}; + box-shadow: none; + } + .rc-slider-handle-click-focused:focus { + border-color: ${theme.palette.blue77}; + } + .rc-slider-dot-active { + border-color: ${theme.palette.blue77}; + } + .rc-slider-track { + background-color: ${theme.palette.blue77}; + } + .rc-slider-rail { + background-color: ${trackColor}; + border: 1px solid ${trackColor}; + } + `, + /** Global component from @emotion/core doesn't accept computed classname string returned from css from emotion. + * It accepts object containing the computed name and flattened styles returned from css from @emotion/core + * */ + tooltip: cssCore` + body { + .rc-slider-tooltip { + cursor: grab; + user-select: none; + z-index: ${theme.zIndex.tooltip}; + } + + .rc-slider-tooltip-inner { + color: ${theme.colors.text}; + background-color: transparent !important; + border-radius: 0; + box-shadow: none; + } + + .rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow { + display: none; + } + + .rc-slider-tooltip-placement-top { + padding: 0; + } + } + `, + sliderInput: css` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + `, + sliderInputVertical: css` + flex-direction: column; + height: 100%; + + .rc-slider { + margin: 0; + order: 2; + } + `, + sliderInputField: css` + display: flex; + flex-grow: 0; + flex-basis: 50px; + margin-left: ${theme.spacing.lg}; + height: ${theme.spacing.formInputHeight}px; + text-align: center; + border-radius: ${theme.border.radius.sm}; + border: 1px solid ${theme.colors.formInputBorder}; + ${getFocusStyle(theme)}; + `, + sliderInputFieldVertical: css` + margin: 0 0 ${theme.spacing.lg} 0; + order: 1; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/Slider/types.ts b/packages/grafana-ui/src/components/Slider/types.ts new file mode 100644 index 00000000000..7838465b79c --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/types.ts @@ -0,0 +1,29 @@ +import { Orientation } from '../../types/orientation'; + +export interface SliderProps { + min: number; + max: number; + orientation?: Orientation; + /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ + value?: number; + reverse?: boolean; + step?: number; + tooltipAlwaysVisible?: boolean; + formatTooltipResult?: (value: number) => number; + onChange?: (value: number) => void; + onAfterChange?: (value?: number) => void; +} + +export interface RangeSliderProps { + min: number; + max: number; + orientation?: Orientation; + /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ + value?: number[]; + reverse?: boolean; + step?: number; + tooltipAlwaysVisible?: boolean; + formatTooltipResult?: (value: number) => number | string; + onChange?: (value: number[]) => void; + onAfterChange?: (value: number[]) => void; +} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index a42b11cbf90..0c6d2421375 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -126,11 +126,13 @@ export { default as Chart } from './Chart'; export { TooltipContainer } from './Chart/TooltipContainer'; export { Drawer } from './Drawer/Drawer'; export { Slider } from './Slider/Slider'; +export { RangeSlider } from './Slider/RangeSlider'; // TODO: namespace!! export { StringValueEditor } from './OptionsUI/string'; export { StringArrayEditor } from './OptionsUI/strings'; export { NumberValueEditor } from './OptionsUI/number'; +export { SliderValueEditor } from './OptionsUI/slider'; export { SelectValueEditor } from './OptionsUI/select'; export { FieldConfigItemHeaderTitle } from './FieldConfigs/FieldConfigItemHeaderTitle'; diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx index c70a279478a..8c45c5d0a41 100644 --- a/packages/grafana-ui/src/utils/standardEditors.tsx +++ b/packages/grafana-ui/src/utils/standardEditors.tsx @@ -25,6 +25,7 @@ import { import { Switch } from '../components/Switch/Switch'; import { NumberValueEditor, + SliderValueEditor, RadioButtonGroup, StringValueEditor, StringArrayEditor, @@ -229,6 +230,13 @@ export const getStandardOptionEditors = () => { editor: NumberValueEditor as any, }; + const slider: StandardEditorsRegistryItem = { + id: 'slider', + name: 'Slider', + description: 'Allows numeric values input', + editor: SliderValueEditor as any, + }; + const text: StandardEditorsRegistryItem = { id: 'text', name: 'Text', @@ -323,6 +331,7 @@ export const getStandardOptionEditors = () => { return [ text, number, + slider, boolean, radio, select, diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx index fe74db6ca76..9d0c92c7d03 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx @@ -3,7 +3,7 @@ import { mount } from 'enzyme'; import { ExploreId } from '../../../types/explore'; import { SortOrder } from 'app/core/utils/richHistory'; import { RichHistoryQueriesTab, Props } from './RichHistoryQueriesTab'; -import { Slider } from '@grafana/ui'; +import { RangeSlider } from '@grafana/ui'; jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); @@ -30,7 +30,7 @@ describe('RichHistoryQueriesTab', () => { describe('slider', () => { it('should render slider', () => { const wrapper = setup(); - expect(wrapper.find(Slider)).toHaveLength(1); + expect(wrapper.find(RangeSlider)).toHaveLength(1); }); it('should render slider with correct timerange', () => { const wrapper = setup(); diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index fad75758906..e286915a123 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -20,7 +20,7 @@ import { // Components import RichHistoryCard from './RichHistoryCard'; import { sortOrderOptions } from './RichHistory'; -import { Slider, Select } from '@grafana/ui'; +import { RangeSlider, Select } from '@grafana/ui'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; export interface Props { @@ -186,7 +186,7 @@ export function RichHistoryQueriesTab(props: Props) {
      Filter history
      {mapNumbertoTimeInSlider(timeFilter[0])}
      - (GraphPane description: '', defaultValue: true, }) - .addSelect({ + .addSliderInput({ path: 'line.width', name: 'Line width', defaultValue: 1, settings: { - options: [ - { value: 1, label: '1 • thin' }, - { value: 2, label: '2' }, - { value: 3, label: '3' }, - { value: 4, label: '4' }, - { value: 5, label: '5' }, - { value: 6, label: '6' }, - { value: 7, label: '7' }, - { value: 8, label: '8' }, - { value: 9, label: '9' }, - { value: 10, label: '10 • thick' }, - ], + min: 1, + max: 10, + step: 1, }, showIf: c => { return c.line.show; @@ -52,23 +43,14 @@ export const plugin = new PanelPlugin(GraphPane description: '', defaultValue: false, }) - .addSelect({ + .addSliderInput({ path: 'points.radius', name: 'Point radius', defaultValue: 4, settings: { - options: [ - { value: 1, label: '1 • thin' }, - { value: 2, label: '2' }, - { value: 3, label: '3' }, - { value: 4, label: '4' }, - { value: 5, label: '5' }, - { value: 6, label: '6' }, - { value: 7, label: '7' }, - { value: 8, label: '8' }, - { value: 9, label: '9' }, - { value: 10, label: '10 • thick' }, - ], + min: 1, + max: 10, + step: 1, }, showIf: c => c.points.show, }) @@ -78,24 +60,14 @@ export const plugin = new PanelPlugin(GraphPane description: '', defaultValue: false, }) - .addSelect({ + .addSliderInput({ path: 'fill.alpha', name: 'Fill area opacity', defaultValue: 0.1, settings: { - options: [ - { value: 0, label: 'No Fill' }, - { value: 0.1, label: '10% • transparent' }, - { value: 0.2, label: '20%' }, - { value: 0.3, label: '30%' }, - { value: 0.4, label: '40% ' }, - { value: 0.5, label: '50%' }, - { value: 0.6, label: '60%' }, - { value: 0.7, label: '70%' }, - { value: 0.8, label: '80%' }, - { value: 0.9, label: '90%' }, - { value: 1, label: '100% • opaque' }, - ], + min: 0, + max: 1, + step: 0.1, }, }) .addTextInput({ From aac392c32f20f0554947c175073850b8abda4045 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 28 Oct 2020 15:39:07 +0100 Subject: [PATCH 023/132] Docs: data source insights (#28542) * Docs: data source insights * wording * review feedback * screenshot * feedback --- docs/sources/enterprise/usage-insights.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/sources/enterprise/usage-insights.md b/docs/sources/enterprise/usage-insights.md index 2e82a32fb8a..09cfee0ec03 100644 --- a/docs/sources/enterprise/usage-insights.md +++ b/docs/sources/enterprise/usage-insights.md @@ -55,3 +55,21 @@ It shows two kinds of information: In the search view, you can sort dashboards using these insights data. It helps you find unused or broken dashboards or discover most viewed ones. {{< docs-imagebox img="/img/docs/enterprise/improved_search.png" max-width="650px" class="docs-image--no-shadow" >}} + +## Data source insights + +> Only available in Grafana Enterprise v7.3+. + +Data source insights give you information about how a data source has been used in the last thirty days. + +- Queries per day +- Errors per day +- Average load duration per day (ms) + +To find data source insights, go to: +1. Data source list view +1. Click on a data source +1. Click the insights tab + +{{< docs-imagebox img="/img/docs/enterprise/datasource_insights.png" max-width="650px" class="docs-image--no-shadow" >}} + From 7401559c3ed4f8800b52a8f764f35b5d102a6f22 Mon Sep 17 00:00:00 2001 From: Kyle Brandt Date: Wed, 28 Oct 2020 10:55:05 -0400 Subject: [PATCH 024/132] update latest.json (#28603) --- latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/latest.json b/latest.json index 613d8efff16..b0d7aa5b7fb 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "7.2.2", - "testing": "7.3.0-beta2" + "stable": "7.3.0", + "testing": "7.3.0" } From 4301121b9d96a4567dfb69f041780ca25e9368d3 Mon Sep 17 00:00:00 2001 From: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> Date: Wed, 28 Oct 2020 08:56:55 -0700 Subject: [PATCH 025/132] Docs: Update graph panel for tabs (#28552) * Update data-links.md * Update data-links.md * content updates * Update data-links.md --- docs/sources/linking/data-links.md | 14 ++++++-------- .../panels/field-options/standard-field-options.md | 2 ++ docs/sources/panels/visualizations/graph-panel.md | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/sources/linking/data-links.md b/docs/sources/linking/data-links.md index 791da258202..a7158d0508b 100644 --- a/docs/sources/linking/data-links.md +++ b/docs/sources/linking/data-links.md @@ -9,11 +9,9 @@ aliases = ["/docs/grafana/latest/reference/datalinks/"] Data links allow you to provide more granular context to your links. You can create links that include the series name or even the value under the cursor. For example, if your visualization showed four servers, you could add a data link to one or two of them. -The link itself is accessible in different ways depending on the visualization. For the graph you need to click on a data point or line, for a panel like +The link itself is accessible in different ways depending on the visualization. For the Graph you need to click on a data point or line, for a panel like Stat, Gauge, or Bar Gauge you can click anywhere on the visualization to open the context menu. -> **Note:** For stat, gauge, bar gauge, and table visualizations, you add and edit data links on the Field tab. For the graph visualization, you add and edit data links on the Panel tab. - You can use variables in data links to send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a time range, series, and variable selection. For more information, refer to [Data link variables]({{< relref "data-link-variables.md" >}}). ## Typeahead suggestions @@ -25,7 +23,7 @@ When creating or updating a data link, press Cmd+Space or Ctrl+Space on your key ## Add a data link 1. Hover your cursor over the panel that you want to add a link to and then press `e`. Or click the dropdown arrow next to the panel title and then click **Edit**. -1. On the Field tab, scroll down to the Data links section. (Panel tab for graph visualizations.) +1. On the Field tab, scroll down to the Data links section. 1. Expand Data links and then click **Add link**. 1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI. 1. Enter the **URL** you want to link to. @@ -38,14 +36,14 @@ When creating or updating a data link, press Cmd+Space or Ctrl+Space on your key ## Update a data link -1. On the Field tab, find the link that you want to make changes to. (Panel tab for graph visualizations.) -1. Click the Edit (pencil) icon to open the Edit link window. +1. On the Field tab, find the link that you want to make changes to. +1. Click the Edit (pencil) icon to open the Edit link window. 1. Make any necessary changes. 1. Click **Save** to save changes and close the window. 1. Click **Save** in the upper right to save your changes to the dashboard. ## Delete a data link -1. On the Field tab, find the link that you want to delete. (Panel tab for graph visualizations.) -1. Click the **X** icon next to the link you want to delete. +1. On the Field tab, find the link that you want to delete. +1. Click the **X** icon next to the link you want to delete. 1. Click **Save** in the upper right to save your changes to the dashboard. diff --git a/docs/sources/panels/field-options/standard-field-options.md b/docs/sources/panels/field-options/standard-field-options.md index e35dd2a211c..8f81f293ad6 100644 --- a/docs/sources/panels/field-options/standard-field-options.md +++ b/docs/sources/panels/field-options/standard-field-options.md @@ -18,6 +18,8 @@ For more information about applying these options, refer to: - [Configure all fields]({{< relref "configure-all-fields.md" >}}) - [Configure specific fields]({{< relref "configure-specific-fields.md" >}}) +> **Note:** We are constantly working to add and expand options for all visualization, so all options might not be available for all visualizations. + ## Decimals Number of decimals to render value with. Leave empty for Grafana to use the number of decimals provided by the data source. diff --git a/docs/sources/panels/visualizations/graph-panel.md b/docs/sources/panels/visualizations/graph-panel.md index 340047560a2..17a9e882e64 100644 --- a/docs/sources/panels/visualizations/graph-panel.md +++ b/docs/sources/panels/visualizations/graph-panel.md @@ -16,8 +16,9 @@ This visualization is the most-used in the Grafana ecosystem. It can render as a Graph visualizations allow you to apply: -- [Data transformations]({{< relref "../transformations/_index.md" >}}) - [Alerts]({{< relref "../../alerting/alerts-overview.md" >}}) - This is the only type of visualization that allows you to set alerts. +- [Data transformations]({{< relref "../transformations/_index.md" >}}) +- [Field options and overrides]({{< relref "../field-options/_index.md" >}}) - [Thresholds]({{< relref "../thresholds.md" >}}) ## Display options From 4ec60c7187d64899cc2a583257368e228f568285 Mon Sep 17 00:00:00 2001 From: Arve Knudsen Date: Thu, 29 Oct 2020 08:04:37 +0100 Subject: [PATCH 026/132] Developer guide: Update wrt. Windows (#28559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Developer guide: Update wrt. Windows Signed-off-by: Arve Knudsen * Update contribute/developer-guide.md Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update contribute/developer-guide.md Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> --- contribute/developer-guide.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 1765fee723e..d75a0bc0563 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -76,7 +76,10 @@ When you log in for the first time, Grafana asks you to change your password. #### Building on Windows -The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). +The Grafana backend includes SQLite which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). Eventually, if you use [Scoop](https://scoop.sh), you can install GCC through that. + +You can simply build the back-end as follows: `go run build.go build`. The Grafana binaries will be in bin\\windows-amd64. +Alternately, if you wish to use the `make` command, install [Make for Windows](http://gnuwin32.sourceforge.net/packages/make.htm) and use it in a Unix shell (f.ex. Git Bash). ## Test Grafana @@ -98,6 +101,13 @@ If you're developing for the backend, run the tests with the standard Go tool: go test -v ./pkg/... ``` +#### On Windows +Running the backend tests on Windows currently needs some tweaking, so use the build.go script: + +``` +go run build.go test +``` + ### Run end-to-end tests The end to end tests in Grafana use [Cypress](https://www.cypress.io/) to run automated scripts in a headless Chromium browser. Read more about our [e2e framework](/contribute/style-guides/e2e.md). From 00508295d13b1c7a0c608aa7643a843463f79a0c Mon Sep 17 00:00:00 2001 From: Arve Knudsen Date: Thu, 29 Oct 2020 08:27:24 +0100 Subject: [PATCH 027/132] CloudWatch: Improve method name, performance optimization (#28632) Signed-off-by: Arve Knudsen --- pkg/tsdb/cloudwatch/query_transformer.go | 2 +- pkg/tsdb/cloudwatch/response_parser.go | 2 +- pkg/tsdb/cloudwatch/time_series_query.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/tsdb/cloudwatch/query_transformer.go b/pkg/tsdb/cloudwatch/query_transformer.go index 8a17f7a73bb..0ce7c1e8be5 100644 --- a/pkg/tsdb/cloudwatch/query_transformer.go +++ b/pkg/tsdb/cloudwatch/query_transformer.go @@ -53,7 +53,7 @@ func (e *cloudWatchExecutor) transformRequestQueriesToCloudWatchQueries(requestQ return cloudwatchQueries, nil } -func (e *cloudWatchExecutor) transformQueryResponseToQueryResult(cloudwatchResponses []*cloudwatchResponse) map[string]*tsdb.QueryResult { +func (e *cloudWatchExecutor) transformQueryResponsesToQueryResult(cloudwatchResponses []*cloudwatchResponse) map[string]*tsdb.QueryResult { responsesByRefID := make(map[string][]*cloudwatchResponse) refIDs := sort.StringSlice{} for _, res := range cloudwatchResponses { diff --git a/pkg/tsdb/cloudwatch/response_parser.go b/pkg/tsdb/cloudwatch/response_parser.go index c798aeb1752..b353ce97f33 100644 --- a/pkg/tsdb/cloudwatch/response_parser.go +++ b/pkg/tsdb/cloudwatch/response_parser.go @@ -46,7 +46,7 @@ func (e *cloudWatchExecutor) parseResponse(metricDataOutputs []*cloudwatch.GetMe } } - cloudWatchResponses := make([]*cloudwatchResponse, 0) + cloudWatchResponses := make([]*cloudwatchResponse, 0, len(mdrs)) for id, lr := range mdrs { query := queries[id] frames, partialData, err := parseMetricResults(lr, labels[id], query) diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index 2fd1852bcbd..1385cc90df7 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -97,7 +97,7 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo } cloudwatchResponses = append(cloudwatchResponses, responses...) - res := e.transformQueryResponseToQueryResult(cloudwatchResponses) + res := e.transformQueryResponsesToQueryResult(cloudwatchResponses) for _, queryRes := range res { resultChan <- queryRes } From 645412f04b4ce84df8864ceb0d3b8880118b6ad6 Mon Sep 17 00:00:00 2001 From: hborchardt <66408901+hborchardt@users.noreply.github.com> Date: Thu, 29 Oct 2020 09:51:25 +0100 Subject: [PATCH 028/132] Dashboard: Fix navigation from one SoloPanelPage to another one (#28578) * Add test for SoloPanelPage.tsx * Fix navigation from one SoloPanelPage to another one The panel did not update because it assumed that the dashboard was already fully loaded. --- .../containers/SoloPanelPage.test.tsx | 142 ++++++++++++++++++ .../dashboard/containers/SoloPanelPage.tsx | 8 +- 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 public/app/features/dashboard/containers/SoloPanelPage.test.tsx diff --git a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx new file mode 100644 index 00000000000..a050a7d8256 --- /dev/null +++ b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SoloPanelPage, Props } from './SoloPanelPage'; +import { Props as DashboardPanelProps } from '../dashgrid/DashboardPanel'; +import { DashboardModel } from '../state'; +import { DashboardRouteInfo } from 'app/types'; + +jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); +jest.mock('app/features/dashboard/dashgrid/DashboardPanel', () => { + class DashboardPanel extends React.Component { + render() { + // In this test we only check whether a new panel has arrived in the props + return <>{this.props.panel?.title}; + } + } + + return { DashboardPanel }; +}); + +interface ScenarioContext { + dashboard?: DashboardModel | null; + secondaryDashboard?: DashboardModel | null; + setDashboard: (overrides?: any, metaOverrides?: any) => void; + setSecondaryDashboard: (overrides?: any, metaOverrides?: any) => void; + mount: (propOverrides?: Partial) => void; + rerender: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function soloPanelPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + setup: fn => { + setupFn = fn; + }, + setDashboard: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + }, + setSecondaryDashboard: (overrides?: any, metaOverrides?: any) => { + ctx.secondaryDashboard = getTestDashboard(overrides, metaOverrides); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + urlSlug: 'my-dash', + $scope: {}, + urlUid: '11', + urlPanelId: '1', + $injector: {}, + routeInfo: DashboardRouteInfo.Normal, + initDashboard: jest.fn(), + dashboard: null, + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + let { rerender } = render(); + // prop updates will be submitted by rerendering the same component with different props + ctx.rerender = (newProps: Partial) => { + Object.assign(props, newProps); + rerender(); + }; + }, + rerender: () => { + // will be replaced while mount() is called + }, + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('SoloPanelPage', () => { + soloPanelPageScenario('Given initial state', ctx => { + ctx.setup(() => { + ctx.mount(); + }); + + it('Should render nothing', () => { + expect(screen.queryByText(/Loading/)).not.toBeNull(); + }); + }); + + soloPanelPageScenario('Dashboard init completed ', ctx => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboard(); + expect(ctx.dashboard).not.toBeNull(); + // the componentDidMount will change the dashboard prop to the new dashboard + // emulate this by rerendering with new props + ctx.rerender({ dashboard: ctx.dashboard }); + }); + + it('Should render dashboard grid', async () => { + // check if the panel title has arrived in the DashboardPanel mock + expect(screen.queryByText(/My graph/)).not.toBeNull(); + }); + }); + + soloPanelPageScenario('When user navigates to other SoloPanelPage', ctx => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboard({ uid: 1, panels: [{ id: 1, type: 'graph', title: 'Panel 1' }] }); + ctx.setSecondaryDashboard({ uid: 2, panels: [{ id: 1, type: 'graph', title: 'Panel 2' }] }); + }); + + it('Should show other graph', () => { + // check that the title in the DashboardPanel has changed + ctx.rerender({ dashboard: ctx.dashboard }); + expect(screen.queryByText(/Panel 1/)).not.toBeNull(); + ctx.rerender({ dashboard: ctx.secondaryDashboard }); + expect(screen.queryByText(/Panel 1/)).toBeNull(); + expect(screen.queryByText(/Panel 2/)).not.toBeNull(); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 6fa93e8e04f..7f755ba4987 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -13,7 +13,7 @@ import { initDashboard } from '../state/initDashboard'; import { StoreState, DashboardRouteInfo } from 'app/types'; import { PanelModel, DashboardModel } from 'app/features/dashboard/state'; -interface Props { +export interface Props { urlPanelId: string; urlUid?: string; urlSlug?: string; @@ -25,7 +25,7 @@ interface Props { dashboard: DashboardModel | null; } -interface State { +export interface State { panel: PanelModel | null; notFound: boolean; } @@ -57,8 +57,8 @@ export class SoloPanelPage extends Component { return; } - // we just got the dashboard! - if (!prevProps.dashboard) { + // we just got a new dashboard + if (!prevProps.dashboard || prevProps.dashboard.uid !== dashboard.uid) { const panelId = parseInt(urlPanelId, 10); // need to expand parent row if this panel is inside a row From a2b3e637058f987c6e881dcfe07cb0ca79de254d Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 29 Oct 2020 10:41:32 +0100 Subject: [PATCH 029/132] Plugin signing: Fix copy on signed plugin notice (#28633) * Fix copy on signed plugin notice * Update public/app/features/plugins/PluginPage.tsx Co-authored-by: Arve Knudsen * Update public/app/features/plugins/PluginPage.tsx Co-authored-by: Arve Knudsen Co-authored-by: Arve Knudsen --- public/app/features/plugins/PluginPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx index bf29f4240a3..4a6d0cb1e20 100644 --- a/public/app/features/plugins/PluginPage.tsx +++ b/public/app/features/plugins/PluginPage.tsx @@ -326,8 +326,9 @@ class PluginPage extends PureComponent {

      Grafana Labs checks each plugin to verify that it has a valid digital signature. Plugin signature verification - is part of our security measure to ensure plugins are safe and trustworthy. Grafana Labs can’t guarantee the - integrity of this unsigned plugin. Ask the plugin author to request it to be signed. + is part of our security measures to ensure plugins are safe and trustworthy. + {plugin.meta.signature !== PluginSignatureStatus.valid && + 'Grafana Labs can’t guarantee the integrity of this unsigned plugin. Ask the plugin author to request it to be signed.'}

      ); From 63c230b670a33296d26b8ee2eb1a0e03ee2376f6 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 29 Oct 2020 11:28:23 +0100 Subject: [PATCH 030/132] Build: support custom build tags (#28609) * Build: support custom build tags * Update build.go Co-authored-by: Arve Knudsen Co-authored-by: Arve Knudsen --- build.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/build.go b/build.go index 82c49814216..906678fd485 100644 --- a/build.go +++ b/build.go @@ -37,6 +37,7 @@ var ( libc string pkgArch string version string = "v1" + buildTags []string // deb & rpm does not support semver so have to handle their version a little differently linuxPackageVersion string = "v1" linuxPackageIteration string = "" @@ -59,11 +60,13 @@ func main() { log.SetFlags(0) var buildIdRaw string + var buildTagsRaw string flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") flag.StringVar(&gocc, "cc", "", "CC") flag.StringVar(&libc, "libc", "", "LIBC") + flag.StringVar(&buildTagsRaw, "build-tags", "", "Sets custom build tags") flag.BoolVar(&cgo, "cgo-enabled", cgo, "Enable cgo") flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") flag.BoolVar(&race, "race", race, "Use race detector") @@ -89,6 +92,10 @@ func main() { return } + if len(buildTagsRaw) > 0 { + buildTags = strings.Split(buildTagsRaw, ",") + } + log.Printf("Version: %s, Linux Version: %s, Package Iteration: %s\n", version, linuxPackageVersion, linuxPackageIteration) if flag.NArg() == 0 { @@ -105,16 +112,16 @@ func main() { case "build-srv", "build-server": clean() - doBuild("grafana-server", "./pkg/cmd/grafana-server", []string{}) + doBuild("grafana-server", "./pkg/cmd/grafana-server", buildTags) case "build-cli": clean() - doBuild("grafana-cli", "./pkg/cmd/grafana-cli", []string{}) + doBuild("grafana-cli", "./pkg/cmd/grafana-cli", buildTags) case "build": //clean() for _, binary := range binaries { - doBuild(binary, "./pkg/cmd/"+binary, []string{}) + doBuild(binary, "./pkg/cmd/"+binary, buildTags) } case "build-frontend": From 1e51d33d859627a532d8107ddeff7abadf8c1e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 29 Oct 2020 13:53:08 +0100 Subject: [PATCH 031/132] Table: Fix image cell mode so that it works with value mappings (#28644) --- packages/grafana-ui/src/components/Table/ImageCell.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/ImageCell.tsx b/packages/grafana-ui/src/components/Table/ImageCell.tsx index 6ddfb318d02..ea137715b11 100644 --- a/packages/grafana-ui/src/components/Table/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/ImageCell.tsx @@ -2,11 +2,13 @@ import React, { FC } from 'react'; import { TableCellProps } from './types'; export const ImageCell: FC = props => { - const { cell, tableStyles, cellProps } = props; + const { field, cell, tableStyles, cellProps } = props; + + const displayValue = field.display!(cell.value); return (
      - +
      ); }; From b46ac2891db257eaa2e1612c2f809b70c38bf485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 29 Oct 2020 14:08:09 +0100 Subject: [PATCH 032/132] StatPanel: Fixed value being under graph and reduced likley hood for white and dark value text mixing (#28641) * StatPanel: Fixed value being under graph and reduced likley hood for white and dark value text mixing * Updated snapshot * Updated storybook config --- .../grafana-ui/.storybook/webpack.config.js | 2 +- .../components/BigValue/BigValueLayout.tsx | 43 ++++++++----------- .../__snapshots__/BigValue.test.tsx.snap | 4 +- packages/grafana-ui/src/utils/colors.ts | 2 +- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js index bfdf4d0f3cc..792759122a4 100644 --- a/packages/grafana-ui/.storybook/webpack.config.js +++ b/packages/grafana-ui/.storybook/webpack.config.js @@ -103,7 +103,7 @@ module.exports = ({ config, mode }) => { minimize: isProductionBuild, minimizer: isProductionBuild ? [ - new TerserPlugin({ cache: false, parallel: false, sourceMap: false, exclude: /monaco/ }), + new TerserPlugin({ cache: false, parallel: false, sourceMap: false, exclude: /monaco|bizcharts/ }), new OptimizeCSSAssetsPlugin({}), ] : [], diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index a5df4c15cd2..95eea803376 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -1,6 +1,7 @@ // Libraries -import React, { CSSProperties, Suspense } from 'react'; +import React, { CSSProperties } from 'react'; import tinycolor from 'tinycolor2'; +import { Chart, Geom } from 'bizcharts'; // Utils import { formattedValueToString, DisplayValue, getColorForTheme } from '@grafana/data'; @@ -13,16 +14,6 @@ import { getTextColorForBackground } from '../../utils'; const LINE_HEIGHT = 1.2; const MAX_TITLE_SIZE = 30; -const Chart = React.lazy(async () => { - const { Chart } = await import(/* webpackChunkName: "bizcharts" */ 'bizcharts'); - return { default: Chart }; -}); - -const Geom = React.lazy(async () => { - const { Geom } = await import(/* webpackChunkName: "bizcharts" */ 'bizcharts'); - return { default: Geom }; -}); - export abstract class BigValueLayout { titleFontSize: number; valueFontSize: number; @@ -72,6 +63,8 @@ export abstract class BigValueLayout { fontSize: this.valueFontSize, fontWeight: 500, lineHeight: LINE_HEIGHT, + position: 'relative', + zIndex: 1, }; switch (this.props.colorMode) { @@ -175,19 +168,17 @@ export abstract class BigValueLayout { } return ( - Loading chart...
      }> - - {this.renderGeom()} - - + + {this.renderGeom()} + ); } @@ -220,10 +211,10 @@ export abstract class BigValueLayout { lineStyle.stroke = lineColor; return ( - Loading chart...
}> + <> - + ); } diff --git a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap index cfaf1b3416b..3f57fd43340 100644 --- a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap +++ b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap @@ -30,10 +30,12 @@ exports[`BigValue Render with basic options should render 1`] = ` 150 ? lightTheme.colors.textStrong : darkTheme.colors.textStrong; + return b > 180 ? lightTheme.colors.textStrong : darkTheme.colors.textStrong; } export let sortedColors = sortColorsByHue(colors); From a03dd3ecf25171601e4af96bc20b14c60d54631f Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 29 Oct 2020 14:50:42 +0100 Subject: [PATCH 033/132] Bump rxjs to 6.6.3 (#28657) --- package.json | 2 +- packages/grafana-data/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9df78b731e8..a44509c6470 100644 --- a/package.json +++ b/package.json @@ -282,7 +282,7 @@ "regenerator-runtime": "0.13.3", "reselect": "4.0.0", "rst2html": "github:thoward/rst2html#990cb89", - "rxjs": "6.6.2", + "rxjs": "6.6.3", "search-query-parser": "1.5.4", "slate": "0.47.8", "slate-plain-serializer": "0.7.10", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 9384fb11ed1..621ae164939 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -26,7 +26,7 @@ "@types/d3-interpolate": "^1.3.1", "apache-arrow": "0.16.0", "lodash": "4.17.19", - "rxjs": "6.6.2", + "rxjs": "6.6.3", "xss": "1.0.6" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index f352748d387..92b8152f6ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24051,10 +24051,10 @@ rxjs@6.5.5, rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.3: dependencies: tslib "^1.9.0" -rxjs@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2" - integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg== +rxjs@6.6.3: + version "6.6.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== dependencies: tslib "^1.9.0" From 2be217e0267aa563441c1ebac34a5a36b323de43 Mon Sep 17 00:00:00 2001 From: Andrey Chugunov Date: Thu, 29 Oct 2020 18:02:09 +0400 Subject: [PATCH 034/132] Docker: use root group in the custom Dockerfile (#28639) --- packaging/docker/custom/Dockerfile | 3 ++- packaging/docker/custom/ubuntu.Dockerfile | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packaging/docker/custom/Dockerfile b/packaging/docker/custom/Dockerfile index 8ead865dedb..9a6fcf93e3b 100644 --- a/packaging/docker/custom/Dockerfile +++ b/packaging/docker/custom/Dockerfile @@ -6,10 +6,11 @@ USER root ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" +ARG GF_GID="0" ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" RUN mkdir -p "$GF_PATHS_PLUGINS" && \ - chown -R grafana:grafana "$GF_PATHS_PLUGINS" + chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ diff --git a/packaging/docker/custom/ubuntu.Dockerfile b/packaging/docker/custom/ubuntu.Dockerfile index 88a2bf1b638..a6c7a8ce485 100644 --- a/packaging/docker/custom/ubuntu.Dockerfile +++ b/packaging/docker/custom/ubuntu.Dockerfile @@ -9,10 +9,11 @@ ARG DEBIAN_FRONTEND=noninteractive ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" +ARG GF_GID="0" ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" RUN mkdir -p "$GF_PATHS_PLUGINS" && \ - chown -R grafana:grafana "$GF_PATHS_PLUGINS" + chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ apt-get update && \ From b9d71f5cddd88a150c3655e5f578e30001617feb Mon Sep 17 00:00:00 2001 From: Will Browne Date: Thu, 29 Oct 2020 15:03:37 +0100 Subject: [PATCH 035/132] Plugins: Fix descendent frontend plugin signature validation (#28638) * move plugin root check to earlier in validation process * remove comment * only check root if necessary --- pkg/plugins/plugins.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 0cffde11c10..0e64fed1b1c 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -373,12 +373,6 @@ func (scanner *PluginScanner) IsBackendOnlyPlugin(pluginType string) bool { // validateSignature validates a plugin's signature. func (s *PluginScanner) validateSignature(plugin *PluginBase) *PluginError { - // For the time being, we choose to only require back-end plugins to be signed - // NOTE: the state is calculated again when setting metadata on the object - if !plugin.Backend || !s.requireSigned { - return nil - } - if plugin.Signature == PluginSignatureValid { s.log.Debug("Plugin has valid signature", "id", plugin.Id) return nil @@ -403,6 +397,12 @@ func (s *PluginScanner) validateSignature(plugin *PluginBase) *PluginError { "state", plugin.Signature) } + // For the time being, we choose to only require back-end plugins to be signed + // NOTE: the state is calculated again when setting metadata on the object + if !plugin.Backend || !s.requireSigned { + return nil + } + switch plugin.Signature { case PluginSignatureUnsigned: allowUnsigned := false From 17bfc8275ffd1e240350340025ac6ccf34e7e411 Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Thu, 29 Oct 2020 15:13:58 +0000 Subject: [PATCH 036/132] Docs: Describe pipeline aggregation changes in v7.3 (#28660) --- docs/sources/whatsnew/whats-new-in-v7-3.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sources/whatsnew/whats-new-in-v7-3.md b/docs/sources/whatsnew/whats-new-in-v7-3.md index ce89346fdc2..1aa8c73f64b 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-3.md +++ b/docs/sources/whatsnew/whats-new-in-v7-3.md @@ -84,6 +84,10 @@ You can now configure your Elasticsearch data source to access your Amazon Elast For more details, refer to the [Elasticsearch docs]({{}}). +## Chaining pipeline aggregation in Elasticsearch + +Thanks to a contribution from a community member, it's now possible to chain multiple pipeline aggregations together and use the results of one pipeline aggregation as the input of another. This unleashes the full power of Elasticsearch's pipeline aggregations in Grafana, allowing users to perform high order derivatives or use a pipeline aggregation result as a variable for a Bucket Script Aggregation. + ## Grafana Enterprise features These features are included in the Grafana Enterprise edition software. From 9717ec77e245b6973c9870a4079fa729f2b4c8db Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 29 Oct 2020 16:35:11 +0100 Subject: [PATCH 037/132] Add info about CSV download for Excel in What's new article (#28661) * Add info about CSV download for Excel in What's new article * Update docs/sources/whatsnew/whats-new-in-v7-3.md * Update docs/sources/whatsnew/whats-new-in-v7-3.md --- docs/sources/whatsnew/whats-new-in-v7-3.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/sources/whatsnew/whats-new-in-v7-3.md b/docs/sources/whatsnew/whats-new-in-v7-3.md index 1aa8c73f64b..5bc0ab4ae96 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-3.md +++ b/docs/sources/whatsnew/whats-new-in-v7-3.md @@ -22,6 +22,7 @@ The main highlights are: - [**Table improvements and new image cell mode**]({{< relref "#table-improvements-and-new-image-cell-mode" >}}) - [**New color scheme option**]({{< relref "#new-color-scheme-option" >}}) - [**SigV4 Authentication for Amazon Elasticsearch Service**]({{< relref "#sigv4-authentication-for-aws-users" >}}) +- [**CSV exports for Excel**]({{< relref "#csv-exports-for-excel" >}}) ## Table improvements and new image cell mode @@ -59,6 +60,11 @@ As this new option is a standard field option it works in every panel. Here is a {{< figure src="/img/docs/v73/bar_gauge_gradient_color_scheme.png" max-width="900px" caption="bar gauge color scheme" >}} +## CSV exports for Excel + +In v7.0, we introduced a new table panel and inspect mode with Download CSV enabled. However, CSV export to Excel was removed. Due to a large number of inquiries and requests, this [community contribution from tomdaly](https://github.com/grafana/grafana/pull/27284) brought the feature back. + +For more information, refer to [Download raw query results as CSV]({{< relref "../panels/inspect-panel/#download-raw-query-results-as-csv" >}}) in the Grafana documentation. ## Google Cloud monitoring out-of-the-box dashboards From 6dbf1f830a1ba4d297360ebf186ddcaed8394220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Thu, 29 Oct 2020 17:05:36 +0100 Subject: [PATCH 038/132] Cloudwatch: Fix duplicate metric data (#28642) * Cloudwatch: Fix duplicate metric data * Refactor reduce function to for of --- .../datasource/cloudwatch/datasource.test.ts | 33 ++++++++- .../datasource/cloudwatch/datasource.ts | 73 ++++++++++--------- 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index f6b030ae711..926ce3f16e9 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -1,7 +1,7 @@ -import { CloudWatchDatasource } from './datasource'; -import { TemplateSrv } from '../../../features/templating/template_srv'; +import { DataQueryResponse, dateTime, DefaultTimeRange } from '@grafana/data'; import { setBackendSrv } from '@grafana/runtime'; -import { DataQueryResponse, DefaultTimeRange } from '@grafana/data'; +import { TemplateSrv } from '../../../features/templating/template_srv'; +import { CloudWatchDatasource } from './datasource'; describe('datasource', () => { describe('query', () => { @@ -35,6 +35,33 @@ describe('datasource', () => { }); }); + describe('performTimeSeriesQuery', () => { + it('should return the same length of data as result', async () => { + const { datasource } = setup(); + const awsRequestMock = jest.spyOn(datasource, 'awsRequest'); + const buildCloudwatchConsoleUrlMock = jest.spyOn(datasource, 'buildCloudwatchConsoleUrl'); + buildCloudwatchConsoleUrlMock.mockImplementation(() => ''); + awsRequestMock.mockImplementation(async () => { + return { + results: { + a: { refId: 'a', series: [{ name: 'cpu', points: [1, 1] }], meta: { gmdMeta: '' } }, + b: { refId: 'b', series: [{ name: 'memory', points: [2, 2] }], meta: { gmdMeta: '' } }, + }, + }; + }); + const response: DataQueryResponse = await datasource.performTimeSeriesQuery( + { + queries: [ + { datasourceId: 1, refId: 'a' }, + { datasourceId: 1, refId: 'b' }, + ], + } as any, + { from: dateTime(), to: dateTime() } as any + ); + expect(response.data.length).toEqual(2); + }); + }); + describe('describeLogGroup', () => { it('replaces region correctly in the query', async () => { const { datasource, datasourceRequestMock } = setup(); diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index a82dfd1c120..7b859d4df15 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -595,44 +595,45 @@ export class CloudWatchDatasource extends DataSourceApi { - const queryResult = res.results[queryRequest.refId]; - if (!queryResult) { - return { data, error }; + const data = dataframes.map(frame => { + const queryResult = res.results[frame.refId!]; + const error = queryResult.error ? { message: queryResult.error } : null; + if (!queryResult) { + return { frame, error }; + } + + const requestQuery = request.queries.find(q => q.refId === frame.refId!) as any; + + const link = this.buildCloudwatchConsoleUrl( + requestQuery!, + from.toISOString(), + to.toISOString(), + frame.refId!, + queryResult.meta.gmdMeta + ); + + if (link) { + for (const field of frame.fields) { + field.config.links = [ + { + url: link, + title: 'View in CloudWatch console', + targetBlank: true, + }, + ]; } + } + return { frame, error }; + }); - const link = this.buildCloudwatchConsoleUrl( - queryRequest, - from.toISOString(), - to.toISOString(), - queryRequest.refId, - queryResult.meta.gmdMeta - ); - - return { - error: error || queryResult.error ? { message: queryResult.error } : null, - data: [ - ...data, - ...dataframes.map(frame => { - if (link) { - for (const field of frame.fields) { - field.config.links = [ - { - url: link, - title: 'View in CloudWatch console', - targetBlank: true, - }, - ]; - } - } - return frame; - }), - ], - }; - }, - { data: [], error: null } - ); + return { + data: data.map(o => o.frame), + error: data + .map(o => o.error) + .reduce((err, error) => { + return err || error; + }, null), + }; } catch (err) { if (/^Throttling:.*/.test(err.data.message)) { const failedRedIds = Object.keys(err.data.results); From 5a83fc574aaf73a8143f921c84dff6a8b59e0549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 29 Oct 2020 19:48:54 +0100 Subject: [PATCH 039/132] PanelMenu: Fixes panel submenu not being accessible for panels close to the right edge of the screen (#28666) * Dropdowns: Trying to fix dropdown menus * Dropdowns: Trying to fix dropdown menus * removed now unnessary wrapper ref * Upodates * Remove export --- .../components/SubMenu/DashboardLinks.tsx | 2 +- .../SubMenu/DashboardLinksDashboard.tsx | 48 ++++++++++++------- .../PanelHeader/PanelHeaderMenuItem.tsx | 25 +++++++++- public/sass/components/_dropdown.scss | 12 +++-- public/sass/components/_gf-form.scss | 5 ++ 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx index d733eab69fe..abab921936b 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -44,7 +44,7 @@ export const DashboardLinks: FC = ({ dashboard, links }) => { const linkElement = ( { state: State = { resolvedLinks: [] }; - wrapperRef = createRef(); listItemRef = createRef(); componentDidMount() { @@ -45,7 +44,7 @@ export class DashboardLinksDashboard extends PureComponent { const { link } = this.props; return ( -
+
{link.tooltip && {linkElement}} {!link.tooltip && <>{linkElement}}
@@ -62,7 +61,7 @@ export class DashboardLinksDashboard extends PureComponent { resolvedLinks.map((resolvedLink, index) => { const linkElement = (
{ ); }; - getDropdownLocationCssClass = (): string => { - const [pullLeftCssClass, pullRightCssClass] = ['pull-left', 'pull-right']; - const wrapper = this.wrapperRef.current; - const list = this.listItemRef.current; - if (!wrapper || !list) { - return pullRightCssClass; - } - return wrapper.offsetLeft > list.offsetWidth - wrapper.offsetWidth ? pullRightCssClass : pullLeftCssClass; - }; - - renderDropdown = () => { + renderDropdown() { const { link, linkInfo } = this.props; const { resolvedLinks } = this.state; const linkElement = ( <> - + {linkInfo.title} -