From eccc6adfde6f910fb49c26b763fe58a3510ed9f1 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 9 Sep 2019 21:58:34 -0700 Subject: [PATCH 01/87] TimeSeries: datasources with labels should export tags (not labels) (#18977) * merge master * export prometheus tags --- .../datasource/prometheus/result_transformer.ts | 2 +- .../prometheus/specs/result_transformer.test.ts | 12 ++++++------ public/app/plugins/datasource/testdata/datasource.ts | 7 ++++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index e365041bd989..50c8d95e375a 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -76,7 +76,7 @@ export class ResultTransformer { datapoints: dps, query: options.query, target: metricLabel, - labels: metricData.metric, + tags: metricData.metric, }; } diff --git a/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts index 5f7d21763779..dd451e439f58 100644 --- a/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts @@ -151,19 +151,19 @@ describe('Prometheus Result Transformer', () => { target: '1', query: undefined, datapoints: [[10, 1445000010000], [10, 1445000020000], [0, 1445000030000]], - labels: { __name__: 'test', job: 'testjob', le: '1' }, + tags: { __name__: 'test', job: 'testjob', le: '1' }, }, { target: '2', query: undefined, datapoints: [[10, 1445000010000], [0, 1445000020000], [30, 1445000030000]], - labels: { __name__: 'test', job: 'testjob', le: '2' }, + tags: { __name__: 'test', job: 'testjob', le: '2' }, }, { target: '3', query: undefined, datapoints: [[10, 1445000010000], [0, 1445000020000], [10, 1445000030000]], - labels: { __name__: 'test', job: 'testjob', le: '3' }, + tags: { __name__: 'test', job: 'testjob', le: '3' }, }, ]); }); @@ -225,7 +225,7 @@ describe('Prometheus Result Transformer', () => { target: 'test{job="testjob"}', query: undefined, datapoints: [[10, 0], [10, 1000], [0, 2000]], - labels: { job: 'testjob' }, + tags: { job: 'testjob' }, }, ]); }); @@ -256,7 +256,7 @@ describe('Prometheus Result Transformer', () => { target: 'test{job="testjob"}', query: undefined, datapoints: [[null, 0], [10, 1000], [0, 2000]], - labels: { job: 'testjob' }, + tags: { job: 'testjob' }, }, ]); }); @@ -287,7 +287,7 @@ describe('Prometheus Result Transformer', () => { target: 'test{job="testjob"}', query: undefined, datapoints: [[null, 0], [null, 2000], [10, 4000], [null, 6000], [10, 8000]], - labels: { job: 'testjob' }, + tags: { job: 'testjob' }, }, ]); }); diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index 38a5acc39a66..2605e7796789 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -81,7 +81,12 @@ export class TestDataDataSource extends DataSourceApi { } for (const series of results.series || []) { - data.push({ target: series.name, datapoints: series.points, refId: query.refId }); + data.push({ + target: series.name, + datapoints: series.points, + refId: query.refId, + tags: series.tags, + }); } } From 0e3e874eeedc2d0abc617634deb1e5b8fdf01750 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 10 Sep 2019 11:04:44 +0200 Subject: [PATCH 02/87] Annotations: Add annotations support to Loki (#18949) --- .../sources/plugins/developing/datasources.md | 3 +- .../grafana-data/src/utils/dataFrameView.ts | 6 ++ packages/grafana-ui/src/types/datasource.ts | 22 ++++++ public/app/core/angular_wrappers.ts | 7 ++ .../features/annotations/annotations_srv.ts | 3 +- .../loki/LokiAnnotationsQueryCtrl.tsx | 17 +++++ .../components/AnnotationsQueryEditor.tsx | 54 ++++++++++++++ .../datasource/loki/datasource.test.ts | 73 ++++++++++++++++--- .../app/plugins/datasource/loki/datasource.ts | 59 ++++++++++++++- public/app/plugins/datasource/loki/module.ts | 2 + .../loki/partials/annotations.editor.html | 5 ++ .../app/plugins/datasource/loki/plugin.json | 2 +- 12 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx create mode 100644 public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx create mode 100644 public/app/plugins/datasource/loki/partials/annotations.editor.html diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index 7be1b7548653..05d291f6d54a 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -146,7 +146,8 @@ Request object passed to datasource.annotationQuery function: "datasource": "generic datasource", "enable": true, "name": "annotation name" - } + }, + "dashboard": DashboardModel } ``` diff --git a/packages/grafana-data/src/utils/dataFrameView.ts b/packages/grafana-data/src/utils/dataFrameView.ts index 1838779478cd..1cf833e9dedc 100644 --- a/packages/grafana-data/src/utils/dataFrameView.ts +++ b/packages/grafana-data/src/utils/dataFrameView.ts @@ -68,4 +68,10 @@ export class DataFrameView implements Vector { toJSON(): T[] { return this.toArray(); } + + forEachRow(iterator: (row: T) => void) { + for (let i = 0; i < this.data.length; i++) { + iterator(this.get(i)); + } + } } diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index debfa57de2c1..a59413265125 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -8,6 +8,7 @@ import { LogRowModel, LoadingState, DataFrameDTO, + AnnotationEvent, } from '@grafana/data'; import { PluginMeta, GrafanaPlugin } from './plugin'; import { PanelData } from './panel'; @@ -276,6 +277,12 @@ export abstract class DataSourceApi< * Used in explore */ languageProvider?: any; + + /** + * Can be optionally implemented to allow datasource to be a source of annotations for dashboard. To be visible + * in the annotation editor `annotations` capability also needs to be enabled in plugin.json. + */ + annotationQuery?(options: AnnotationQueryRequest): Promise; } export interface QueryEditorProps< @@ -542,3 +549,18 @@ export interface DataSourceSelectItem { meta: DataSourcePluginMeta; sort: string; } + +/** + * Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md + */ +export interface AnnotationQueryRequest { + range: TimeRange; + rangeRaw: RawTimeRange; + // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. + dashboard: any; + annotation: { + datasource: string; + enable: boolean; + name: string; + } & MoreOptions; +} diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index ed03aa9e65c6..42bd5a92739b 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -12,6 +12,7 @@ import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; import { SearchField } from './components/search/SearchField'; import { GraphContextMenu } from 'app/plugins/panel/graph/GraphContextMenu'; import ReactProfileWrapper from 'app/features/profile/ReactProfileWrapper'; +import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor'; export function registerAngularDirectives() { react2AngularDirective('sidemenu', SideMenu, []); @@ -102,4 +103,10 @@ export function registerAngularDirectives() { ]); react2AngularDirective('reactProfileWrapper', ReactProfileWrapper, []); + + react2AngularDirective('lokiAnnotationsQueryEditor', LokiAnnotationsQueryEditor, [ + 'expr', + 'onChange', + ['datasource', { watchDepth: 'reference' }], + ]); } diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index bd8ae8aa16e5..5df84b4626f6 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -15,6 +15,7 @@ import { AnnotationEvent } from '@grafana/data'; import DatasourceSrv from '../plugins/datasource_srv'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TimeSrv } from '../dashboard/services/TimeSrv'; +import { DataSourceApi } from '@grafana/ui'; export class AnnotationsSrv { globalAnnotationsPromise: any; @@ -126,7 +127,7 @@ export class AnnotationsSrv { dsPromises.push(datasourcePromise); promises.push( datasourcePromise - .then((datasource: any) => { + .then((datasource: DataSourceApi) => { // issue query against data source return datasource.annotationQuery({ range: range, diff --git a/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx b/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx new file mode 100644 index 000000000000..9797bc846b66 --- /dev/null +++ b/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx @@ -0,0 +1,17 @@ +/** + * Just a simple wrapper for a react component that is actually implementing the query editor. + */ +export class LokiAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + annotation: any; + + /** @ngInject */ + constructor() { + this.annotation.target = this.annotation.target || {}; + this.onQueryChange = this.onQueryChange.bind(this); + } + + onQueryChange(expr: string) { + this.annotation.expr = expr; + } +} diff --git a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx new file mode 100644 index 000000000000..d714844d1a8e --- /dev/null +++ b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx @@ -0,0 +1,54 @@ +// Libraries +import React, { memo } from 'react'; + +// Types +import { DataSourceApi, DataSourceJsonData, DataSourceStatus } from '@grafana/ui'; +import { LokiQuery } from '../types'; +import { useLokiSyntax } from './useLokiSyntax'; +import { LokiQueryFieldForm } from './LokiQueryFieldForm'; + +interface Props { + expr: string; + datasource: DataSourceApi; + onChange: (expr: string) => void; +} + +export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEditor(props: Props) { + const { expr, datasource, onChange } = props; + + // Timerange to get existing labels from. Hard coding like this seems to be good enough right now. + const absolute = { + from: Date.now() - 10000, + to: Date.now(), + }; + + const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax( + datasource.languageProvider, + DataSourceStatus.Connected, + absolute + ); + + const query: LokiQuery = { + refId: '', + expr, + }; + + return ( +
+ onChange(query.expr)} + onRunQuery={() => {}} + history={[]} + panelData={null} + onLoadOptions={setActiveOption} + onLabelsRefresh={refreshLabels} + syntaxLoaded={isSyntaxReady} + absoluteRange={absolute} + {...syntaxProps} + /> +
+ ); +}); diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 7c6e7ecbfba2..c42391343826 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -1,8 +1,8 @@ import LokiDatasource from './datasource'; import { LokiQuery } from './types'; import { getQueryOptions } from 'test/helpers/getQueryOptions'; -import { DataSourceApi } from '@grafana/ui'; -import { DataFrame } from '@grafana/data'; +import { AnnotationQueryRequest, DataSourceApi } from '@grafana/ui'; +import { DataFrame, dateTime } from '@grafana/data'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -22,15 +22,15 @@ describe('LokiDatasource', () => { }, }; - describe('when querying', () => { - const backendSrvMock = { datasourceRequest: jest.fn() }; - const backendSrv = (backendSrvMock as unknown) as BackendSrv; + const backendSrvMock = { datasourceRequest: jest.fn() }; + const backendSrv = (backendSrvMock as unknown) as BackendSrv; - const templateSrvMock = ({ - getAdhocFilters: (): any[] => [], - replace: (a: string) => a, - } as unknown) as TemplateSrv; + const templateSrvMock = ({ + getAdhocFilters: (): any[] => [], + replace: (a: string) => a, + } as unknown) as TemplateSrv; + describe('when querying', () => { const testLimit = makeLimitTest(instanceSettings, backendSrvMock, backendSrv, templateSrvMock, testResp); test('should use default max lines when no limit given', () => { @@ -171,6 +171,37 @@ describe('LokiDatasource', () => { }); }); }); + + describe('annotationQuery', () => { + it('should transform the loki data to annototion response', async () => { + const ds = new LokiDatasource(instanceSettings, backendSrv, templateSrvMock); + backendSrvMock.datasourceRequest = jest.fn(() => + Promise.resolve({ + data: { + streams: [ + { + entries: [{ ts: '2019-02-01T10:27:37.498180581Z', line: 'hello' }], + labels: '{label="value"}', + }, + { + entries: [{ ts: '2019-02-01T12:27:37.498180581Z', line: 'hello 2' }], + labels: '{label2="value2"}', + }, + ], + }, + }) + ); + const query = makeAnnotationQueryRequest(); + + const res = await ds.annotationQuery(query); + expect(res.length).toBe(2); + expect(res[0].text).toBe('hello'); + expect(res[0].tags).toEqual(['value']); + + expect(res[1].text).toBe('hello 2'); + expect(res[1].tags).toEqual(['value2']); + }); + }); }); type LimitTestArgs = { @@ -208,3 +239,27 @@ function makeLimitTest( expect(backendSrvMock.datasourceRequest.mock.calls[0][0].url).toContain(`limit=${expectedLimit}`); }; } + +function makeAnnotationQueryRequest(): AnnotationQueryRequest { + const timeRange = { + from: dateTime(), + to: dateTime(), + }; + return { + annotation: { + expr: '{test=test}', + refId: '', + datasource: 'loki', + enable: true, + name: 'test-annotation', + }, + dashboard: { + id: 1, + } as any, + range: { + ...timeRange, + raw: timeRange, + }, + rangeRaw: timeRange, + }; +} diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 84422e94454a..e0cc078c4290 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -1,7 +1,15 @@ // Libraries import _ from 'lodash'; // Services & Utils -import { dateMath, DataFrame, LogRowModel, LoadingState, DateTime } from '@grafana/data'; +import { + dateMath, + DataFrame, + LogRowModel, + LoadingState, + DateTime, + AnnotationEvent, + DataFrameView, +} from '@grafana/data'; import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query'; import LanguageProvider from './language_provider'; import { logStreamToDataFrame } from './result_transformer'; @@ -15,6 +23,7 @@ import { DataQueryRequest, DataStreamObserver, DataQueryResponse, + AnnotationQueryRequest, } from '@grafana/ui'; import { LokiQuery, LokiOptions, LokiLogsStream, LokiResponse } from './types'; @@ -193,7 +202,7 @@ export class LokiDatasource extends DataSourceApi { } }; - runQueries = async (options: DataQueryRequest) => { + runQueries = async (options: DataQueryRequest): Promise<{ data: DataFrame[] }> => { const queryTargets = options.targets .filter(target => target.expr && !target.hide && !target.live) .map(target => this.prepareQueryTarget(target, options)); @@ -368,6 +377,52 @@ export class LokiDatasource extends DataSourceApi { return { status: 'error', message: message }; }); } + + async annotationQuery(options: AnnotationQueryRequest): Promise { + if (!options.annotation.expr) { + return []; + } + + const query = queryRequestFromAnnotationOptions(options); + const { data } = await this.runQueries(query); + const annotations: AnnotationEvent[] = []; + for (const frame of data) { + const tags = Object.values(frame.labels); + const view = new DataFrameView<{ ts: string; line: string }>(frame); + view.forEachRow(row => { + annotations.push({ + time: new Date(row.ts).valueOf(), + text: row.line, + tags, + }); + }); + } + + return annotations; + } +} + +function queryRequestFromAnnotationOptions(options: AnnotationQueryRequest): DataQueryRequest { + const refId = `annotation-${options.annotation.name}`; + const target: LokiQuery = { refId, expr: options.annotation.expr }; + + return { + requestId: refId, + range: options.range, + targets: [target], + dashboardId: options.dashboard.id, + scopedVars: null, + startTime: Date.now(), + + // This should mean the default defined on datasource is used. + maxDataPoints: 0, + + // Dummy values, are required in type but not used here. + timezone: 'utc', + panelId: 0, + interval: '', + intervalMs: 0, + }; } export default LokiDatasource; diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts index 571fa944fe5b..b1b5bb463708 100644 --- a/public/app/plugins/datasource/loki/module.ts +++ b/public/app/plugins/datasource/loki/module.ts @@ -3,6 +3,7 @@ import Datasource from './datasource'; import LokiStartPage from './components/LokiStartPage'; import LokiQueryField from './components/LokiQueryField'; import LokiQueryEditor from './components/LokiQueryEditor'; +import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl'; export class LokiConfigCtrl { static templateUrl = 'partials/config.html'; @@ -14,4 +15,5 @@ export { LokiConfigCtrl as ConfigCtrl, LokiQueryField as ExploreQueryField, LokiStartPage as ExploreStartPage, + LokiAnnotationsQueryCtrl as AnnotationsQueryCtrl, }; diff --git a/public/app/plugins/datasource/loki/partials/annotations.editor.html b/public/app/plugins/datasource/loki/partials/annotations.editor.html new file mode 100644 index 000000000000..e0d75b578bba --- /dev/null +++ b/public/app/plugins/datasource/loki/partials/annotations.editor.html @@ -0,0 +1,5 @@ + diff --git a/public/app/plugins/datasource/loki/plugin.json b/public/app/plugins/datasource/loki/plugin.json index 58a6b594265f..fefeb67f96bb 100644 --- a/public/app/plugins/datasource/loki/plugin.json +++ b/public/app/plugins/datasource/loki/plugin.json @@ -6,7 +6,7 @@ "metrics": true, "alerting": false, - "annotations": false, + "annotations": true, "logs": true, "streaming": true, From 2c5d1745ca55fbf3ee4bd417fcc919168f90a394 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue, 10 Sep 2019 11:30:25 +0200 Subject: [PATCH 03/87] Explore: Unify background color for fresh logs (#18973) --- .../ThresholdsEditor.test.tsx.snap | 2 -- packages/grafana-ui/src/themes/dark.ts | 1 - packages/grafana-ui/src/themes/light.ts | 1 - packages/grafana-ui/src/types/theme.ts | 1 - public/app/features/explore/LiveLogs.tsx | 17 ++++++++--------- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap b/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap index a662a9db228c..eb61c639d816 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap +++ b/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap @@ -74,7 +74,6 @@ exports[`Render should render with base threshold 1`] = ` Object { "background": Object { "dropdown": "#1f1f20", - "logsFresh": "#5794F240", "scrollbar": "#343436", "scrollbar2": "#343436", }, @@ -236,7 +235,6 @@ exports[`Render should render with base threshold 1`] = ` Object { "background": Object { "dropdown": "#1f1f20", - "logsFresh": "#5794F240", "scrollbar": "#343436", "scrollbar2": "#343436", }, diff --git a/packages/grafana-ui/src/themes/dark.ts b/packages/grafana-ui/src/themes/dark.ts index 5e83e2dd29ab..9bbe3aef04e1 100644 --- a/packages/grafana-ui/src/themes/dark.ts +++ b/packages/grafana-ui/src/themes/dark.ts @@ -75,7 +75,6 @@ const darkTheme: GrafanaTheme = { dropdown: basicColors.dark3, scrollbar: basicColors.dark9, scrollbar2: basicColors.dark9, - logsFresh: '#5794F240', }, }; diff --git a/packages/grafana-ui/src/themes/light.ts b/packages/grafana-ui/src/themes/light.ts index 634462613d30..e87bf744f95f 100644 --- a/packages/grafana-ui/src/themes/light.ts +++ b/packages/grafana-ui/src/themes/light.ts @@ -76,7 +76,6 @@ const lightTheme: GrafanaTheme = { dropdown: basicColors.white, scrollbar: basicColors.gray5, scrollbar2: basicColors.gray5, - logsFresh: '#d8e7ff', }, }; diff --git a/packages/grafana-ui/src/types/theme.ts b/packages/grafana-ui/src/types/theme.ts index fd08aaa0a903..23857cc9c341 100644 --- a/packages/grafana-ui/src/types/theme.ts +++ b/packages/grafana-ui/src/types/theme.ts @@ -96,7 +96,6 @@ export interface GrafanaTheme extends GrafanaThemeCommons { dropdown: string; scrollbar: string; scrollbar2: string; - logsFresh: string; }; colors: { black: string; diff --git a/public/app/features/explore/LiveLogs.tsx b/public/app/features/explore/LiveLogs.tsx index 5565f9dc4d3f..c80e813fabbe 100644 --- a/public/app/features/explore/LiveLogs.tsx +++ b/public/app/features/explore/LiveLogs.tsx @@ -1,8 +1,9 @@ import React, { PureComponent } from 'react'; import { css, cx } from 'emotion'; +import tinycolor from 'tinycolor2'; import { last } from 'lodash'; -import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, getLogRowStyles } from '@grafana/ui'; +import { Themeable, withTheme, GrafanaTheme, getLogRowStyles } from '@grafana/ui'; import { LogsModel, LogRowModel, TimeZone } from '@grafana/data'; import ElapsedTime from './ElapsedTime'; @@ -23,17 +24,15 @@ const getStyles = (theme: GrafanaTheme) => ({ logsRowFresh: css` label: logs-row-fresh; color: ${theme.colors.text}; - background-color: ${selectThemeVariant( - { light: theme.background.logsFresh, dark: theme.background.logsFresh }, - theme.type - )}; + background-color: ${tinycolor(theme.colors.blueLight) + .setAlpha(0.25) + .toString()}; animation: fade 1s ease-out 1s 1 normal forwards; @keyframes fade { from { - background-color: ${selectThemeVariant( - { light: theme.background.logsFresh, dark: theme.background.logsFresh }, - theme.type - )}; + background-color: ${tinycolor(theme.colors.blueLight) + .setAlpha(0.25) + .toString()}; } to { background-color: transparent; From 7d9f6d96fc5cb2caa1e5964a811fdb2a5dbafd45 Mon Sep 17 00:00:00 2001 From: lzd Date: Tue, 10 Sep 2019 19:28:13 +0800 Subject: [PATCH 04/87] Singlestat: render lines on the panel when sparklines are enabled (#18984) --- public/app/plugins/panel/singlestat/module.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index a787765416a4..7ff672a67d54 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -250,11 +250,13 @@ class SingleStatCtrl extends MetricsPanelCtrl { isUtc: dashboard.isTimezoneUtc && dashboard.isTimezoneUtc(), }); + const sparkline: any[] = []; const data = { field: fieldInfo.field, value: val, display: processor(val), scopedVars: _.extend({}, panel.scopedVars), + sparkline, }; data.scopedVars['__name'] = name; @@ -262,7 +264,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { // Get the fields for a sparkline if (panel.sparkline && panel.sparkline.show && fieldInfo.frame.firstTimeField) { - this.data.sparkline = getFlotPairs({ + data.sparkline = getFlotPairs({ xField: fieldInfo.frame.firstTimeField, yField: fieldInfo.field, nullValueMode: panel.nullPointMode, From 7f40dc53170a72362ba32a2838f1f345b98e8739 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki Date: Tue, 10 Sep 2019 16:16:51 +0300 Subject: [PATCH 05/87] Image rendering: Add deprecation warning when PhantomJS is used for rendering images (#18933) * Add deprecation warning * Update pkg/services/rendering/rendering.go Co-Authored-By: Marcus Efraimsson --- pkg/services/rendering/rendering.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 8d6605645fdc..a3d30265140a 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -68,6 +68,8 @@ func (rs *RenderingService) Run(ctx context.Context) error { if plugins.Renderer == nil { rs.log.Info("Backend rendering via phantomJS") + rs.log.Warn("phantomJS is deprecated and will be removed in a future release. " + + "You should consider migrating from phantomJS to grafana-image-renderer plugin.") rs.renderAction = rs.renderViaPhantomJS <-ctx.Done() return nil From ca96d794e2d810afb1735ca79cb23874d22ca705 Mon Sep 17 00:00:00 2001 From: Omar Ahmad Date: Tue, 10 Sep 2019 14:30:38 -0400 Subject: [PATCH 06/87] Units: Adding T,P,E,Z,and Y bytes (#18706) * Adding T and P for bytes Luckily, all the hard work was done before; just added in these prefixes for our production environment. * Future-proofing with other values (why not?) * Yottaflops? * Cutting back down to Peta sizes, except for hashes --- .../grafana-ui/src/utils/valueFormats/categories.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/grafana-ui/src/utils/valueFormats/categories.ts b/packages/grafana-ui/src/utils/valueFormats/categories.ts index fafa2bdd76d6..8f314cef65bb 100644 --- a/packages/grafana-ui/src/utils/valueFormats/categories.ts +++ b/packages/grafana-ui/src/utils/valueFormats/categories.ts @@ -75,6 +75,8 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'TFLOP/s', id: 'tflops', fn: decimalSIPrefix('FLOP/s', 4) }, { name: 'PFLOP/s', id: 'pflops', fn: decimalSIPrefix('FLOP/s', 5) }, { name: 'EFLOP/s', id: 'eflops', fn: decimalSIPrefix('FLOP/s', 6) }, + { name: 'ZFLOP/s', id: 'zflops', fn: decimalSIPrefix('FLOP/s', 7) }, + { name: 'YFLOP/s', id: 'yflops', fn: decimalSIPrefix('FLOP/s', 8) }, ], }, { @@ -123,6 +125,8 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'kibibytes', id: 'kbytes', fn: binarySIPrefix('B', 1) }, { name: 'mebibytes', id: 'mbytes', fn: binarySIPrefix('B', 2) }, { name: 'gibibytes', id: 'gbytes', fn: binarySIPrefix('B', 3) }, + { name: 'tebibytes', id: 'tbytes', fn: binarySIPrefix('B', 4) }, + { name: 'pebibytes', id: 'pbytes', fn: binarySIPrefix('B', 5) }, ], }, { @@ -133,6 +137,8 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'kilobytes', id: 'deckbytes', fn: decimalSIPrefix('B', 1) }, { name: 'megabytes', id: 'decmbytes', fn: decimalSIPrefix('B', 2) }, { name: 'gigabytes', id: 'decgbytes', fn: decimalSIPrefix('B', 3) }, + { name: 'terabytes', id: 'dectbytes', fn: decimalSIPrefix('B', 4) }, + { name: 'petabytes', id: 'decpbytes', fn: decimalSIPrefix('B', 5) }, ], }, { @@ -147,6 +153,10 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'megabits/sec', id: 'Mbits', fn: decimalSIPrefix('bps', 2) }, { name: 'gigabytes/sec', id: 'GBs', fn: decimalSIPrefix('Bs', 3) }, { name: 'gigabits/sec', id: 'Gbits', fn: decimalSIPrefix('bps', 3) }, + { name: 'terabytes/sec', id: 'TBs', fn: decimalSIPrefix('Bs', 4) }, + { name: 'terabits/sec', id: 'Gbits', fn: decimalSIPrefix('bps', 4) }, + { name: 'petabytes/sec', id: 'PBs', fn: decimalSIPrefix('Bs', 5) }, + { name: 'petabits/sec', id: 'Pbits', fn: decimalSIPrefix('bps', 5) }, ], }, { From 7520ebadacf2f5276a1f187f1bf4cfc4b953ab23 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 10 Sep 2019 12:06:01 -0700 Subject: [PATCH 07/87] Refactor: move ScopedVars to grafana/data (#18992) --- packages/grafana-data/src/types/ScopedVars.ts | 9 +++++ packages/grafana-data/src/types/index.ts | 1 + .../src/utils/dataFrameHelper.test.ts | 7 +++- .../grafana-data/src/utils/dataFrameHelper.ts | 33 ++++++++++++------- .../src/services/dataSourceSrv.ts | 3 +- .../src/components/Table/Table.story.tsx | 4 +-- packages/grafana-ui/src/types/datasource.ts | 11 +------ packages/grafana-ui/src/types/panel.ts | 4 +-- packages/grafana-ui/src/utils/fieldDisplay.ts | 3 +- .../dashboard/dashgrid/PanelChrome.tsx | 4 +-- .../dashgrid/PanelHeader/PanelHeader.tsx | 2 +- .../PanelHeader/PanelHeaderCorner.tsx | 4 +-- .../features/dashboard/state/PanelModel.ts | 4 +-- .../dashboard/state/PanelQueryRunner.test.ts | 4 +-- .../dashboard/state/PanelQueryRunner.ts | 4 +-- .../panel/panellinks/linkSuppliers.ts | 4 +-- .../app/features/panel/panellinks/link_srv.ts | 4 +-- public/app/features/plugins/datasource_srv.ts | 3 +- .../app/features/templating/template_srv.ts | 3 +- .../datasource/cloudwatch/datasource.ts | 4 +-- .../plugins/datasource/graphite/datasource.ts | 3 +- .../datasource/graphite/graphite_query.ts | 2 +- .../datasource/influxdb/influx_query_model.ts | 2 +- .../plugins/datasource/mysql/mysql_query.ts | 2 +- .../datasource/postgres/postgres_query.ts | 2 +- .../datasource/stackdriver/datasource.ts | 3 +- public/app/plugins/panel/table/renderer.ts | 4 +-- .../panel/table/specs/renderer.test.ts | 3 +- 28 files changed, 77 insertions(+), 59 deletions(-) create mode 100644 packages/grafana-data/src/types/ScopedVars.ts diff --git a/packages/grafana-data/src/types/ScopedVars.ts b/packages/grafana-data/src/types/ScopedVars.ts new file mode 100644 index 000000000000..960aba1599dc --- /dev/null +++ b/packages/grafana-data/src/types/ScopedVars.ts @@ -0,0 +1,9 @@ +export interface ScopedVar { + text: any; + value: T; + [key: string]: any; +} + +export interface ScopedVars { + [key: string]: ScopedVar; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index e0048a067008..08340b2d0104 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -10,3 +10,4 @@ export * from './utils'; export * from './valueMapping'; export * from './displayValue'; export * from './graph'; +export * from './ScopedVars'; diff --git a/packages/grafana-data/src/utils/dataFrameHelper.test.ts b/packages/grafana-data/src/utils/dataFrameHelper.test.ts index 0792d67ba42d..5555c09cd401 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.test.ts +++ b/packages/grafana-data/src/utils/dataFrameHelper.test.ts @@ -13,11 +13,16 @@ describe('dataFrameHelper', () => { }); const ext = new FieldCache(frame); - it('Should get the first field with a duplicate name', () => { + it('should get the first field with a duplicate name', () => { const field = ext.getFieldByName('value'); expect(field!.name).toEqual('value'); expect(field!.values.toJSON()).toEqual([1, 2, 3]); }); + + it('should return index of the field', () => { + const field = ext.getFirstFieldOfType(FieldType.number); + expect(field!.index).toEqual(2); + }); }); describe('FieldCache', () => { diff --git a/packages/grafana-data/src/utils/dataFrameHelper.ts b/packages/grafana-data/src/utils/dataFrameHelper.ts index e569fd7b40c5..d890608d5c47 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.ts +++ b/packages/grafana-data/src/utils/dataFrameHelper.ts @@ -5,16 +5,22 @@ import { ArrayVector, MutableVector, vectorToArray, CircularVector } from './vec import isArray from 'lodash/isArray'; import isString from 'lodash/isString'; +interface FieldWithIndex extends Field { + index: number; +} export class FieldCache { - fields: Field[] = []; + fields: FieldWithIndex[] = []; - private fieldByName: { [key: string]: Field } = {}; - private fieldByType: { [key: string]: Field[] } = {}; + private fieldByName: { [key: string]: FieldWithIndex } = {}; + private fieldByType: { [key: string]: FieldWithIndex[] } = {}; - constructor(private data: DataFrame) { - this.fields = data.fields; + constructor(data: DataFrame) { + this.fields = data.fields.map((field, idx) => ({ + ...field, + index: idx, + })); - for (const field of data.fields) { + for (const [index, field] of data.fields.entries()) { // Make sure it has a type if (field.type === FieldType.other) { const t = guessFieldTypeForField(field); @@ -25,19 +31,22 @@ export class FieldCache { if (!this.fieldByType[field.type]) { this.fieldByType[field.type] = []; } - this.fieldByType[field.type].push(field); + this.fieldByType[field.type].push({ + ...field, + index, + }); if (this.fieldByName[field.name]) { console.warn('Duplicate field names in DataFrame: ', field.name); } else { - this.fieldByName[field.name] = field; + this.fieldByName[field.name] = { ...field, index }; } } } - getFields(type?: FieldType): Field[] { + getFields(type?: FieldType): FieldWithIndex[] { if (!type) { - return [...this.data.fields]; // All fields + return [...this.fields]; // All fields } const fields = this.fieldByType[type]; if (fields) { @@ -51,7 +60,7 @@ export class FieldCache { return types && types.length > 0; } - getFirstFieldOfType(type: FieldType): Field | undefined { + getFirstFieldOfType(type: FieldType): FieldWithIndex | undefined { const arr = this.fieldByType[type]; if (arr && arr.length > 0) { return arr[0]; @@ -66,7 +75,7 @@ export class FieldCache { /** * Returns the first field with the given name. */ - getFieldByName(name: string): Field | undefined { + getFieldByName(name: string): FieldWithIndex | undefined { return this.fieldByName[name]; } } diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 1f3bbbb8436b..5e3b7fe13d60 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -1,4 +1,5 @@ -import { ScopedVars, DataSourceApi } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; +import { DataSourceApi } from '@grafana/ui'; export interface DataSourceSrv { get(name?: string, scopedVars?: ScopedVars): Promise; diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx index ddf537575bb1..2d2c3d2d51bb 100644 --- a/packages/grafana-ui/src/components/Table/Table.story.tsx +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -4,8 +4,8 @@ import { Table } from './Table'; import { getTheme } from '../../themes'; import { migratedTestTable, migratedTestStyles, simpleTable } from './examples'; -import { ScopedVars, GrafanaThemeType } from '../../types/index'; -import { DataFrame, FieldType, ArrayVector } from '@grafana/data'; +import { GrafanaThemeType } from '../../types/index'; +import { DataFrame, FieldType, ArrayVector, ScopedVars } from '@grafana/data'; import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory'; import { number, boolean } from '@storybook/addon-knobs'; diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index a59413265125..5482ac3734cd 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -9,6 +9,7 @@ import { LoadingState, DataFrameDTO, AnnotationEvent, + ScopedVars, } from '@grafana/data'; import { PluginMeta, GrafanaPlugin } from './plugin'; import { PanelData } from './panel'; @@ -435,16 +436,6 @@ export interface DataQueryError { cancelled?: boolean; } -export interface ScopedVar { - text: any; - value: any; - [key: string]: any; -} - -export interface ScopedVars { - [key: string]: ScopedVar; -} - export interface DataQueryRequest { requestId: string; // Used to identify results and optionally cancel the request in backendSrv timezone: string; diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index c6fbb019ba8d..cb0e69d15ea5 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,6 +1,6 @@ import { ComponentClass, ComponentType } from 'react'; -import { LoadingState, DataFrame, TimeRange, TimeZone } from '@grafana/data'; -import { ScopedVars, DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource'; +import { LoadingState, DataFrame, TimeRange, TimeZone, ScopedVars } from '@grafana/data'; +import { DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource'; import { PluginMeta, GrafanaPlugin } from './plugin'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; diff --git a/packages/grafana-ui/src/utils/fieldDisplay.ts b/packages/grafana-ui/src/utils/fieldDisplay.ts index e718396bf18c..cdbb97d01843 100644 --- a/packages/grafana-ui/src/utils/fieldDisplay.ts +++ b/packages/grafana-ui/src/utils/fieldDisplay.ts @@ -8,12 +8,13 @@ import { GraphSeriesValue, DataFrameView, getTimeField, + ScopedVars, } from '@grafana/data'; import toNumber from 'lodash/toNumber'; import toString from 'lodash/toString'; -import { GrafanaTheme, InterpolateFunction, ScopedVars } from '../types/index'; +import { GrafanaTheme, InterpolateFunction } from '../types/index'; import { getDisplayProcessor } from './displayProcessor'; import { getFlotPairs } from './flotPairs'; import { DataLinkBuiltInVars } from '../utils/dataLinks'; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index e6fda12b8a1e..251cf2f19c81 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -17,8 +17,8 @@ import config from 'app/core/config'; // Types import { DashboardModel, PanelModel } from '../state'; -import { ScopedVars, PanelData, PanelPlugin } from '@grafana/ui'; -import { LoadingState } from '@grafana/data'; +import { PanelData, PanelPlugin } from '@grafana/ui'; +import { LoadingState, ScopedVars } from '@grafana/data'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 852476c52def..b38c22408c5e 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import classNames from 'classnames'; import { isEqual } from 'lodash'; -import { ScopedVars } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; import PanelHeaderCorner from './PanelHeaderCorner'; import { PanelHeaderMenu } from './PanelHeaderMenu'; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx index ee79bc4f91c8..507b603f267d 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import { renderMarkdown, LinkModelSupplier } from '@grafana/data'; -import { Tooltip, ScopedVars, PopoverContent } from '@grafana/ui'; +import { renderMarkdown, LinkModelSupplier, ScopedVars } from '@grafana/data'; +import { Tooltip, PopoverContent } from '@grafana/ui'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import templateSrv from 'app/features/templating/template_srv'; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 56d293388cbe..41df0c38153c 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -6,8 +6,8 @@ import { Emitter } from 'app/core/utils/emitter'; import { getNextRefIdChar } from 'app/core/utils/query'; // Types -import { DataQuery, ScopedVars, DataQueryResponseData, PanelPlugin } from '@grafana/ui'; -import { DataLink, DataTransformerConfig } from '@grafana/data'; +import { DataQuery, DataQueryResponseData, PanelPlugin } from '@grafana/ui'; +import { DataLink, DataTransformerConfig, ScopedVars } from '@grafana/data'; import config from 'app/core/config'; diff --git a/public/app/features/dashboard/state/PanelQueryRunner.test.ts b/public/app/features/dashboard/state/PanelQueryRunner.test.ts index 44cb9515b776..75209f54a082 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.test.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.test.ts @@ -1,7 +1,7 @@ import { PanelQueryRunner, QueryRunnerOptions } from './PanelQueryRunner'; -import { PanelData, DataQueryRequest, DataStreamObserver, DataStreamState, ScopedVars } from '@grafana/ui'; +import { PanelData, DataQueryRequest, DataStreamObserver, DataStreamState } from '@grafana/ui'; -import { LoadingState, MutableDataFrame } from '@grafana/data'; +import { LoadingState, MutableDataFrame, ScopedVars } from '@grafana/data'; import { dateTime } from '@grafana/data'; import { SHARED_DASHBODARD_QUERY } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; import { DashboardQuery } from 'app/plugins/datasource/dashboard/types'; diff --git a/public/app/features/dashboard/state/PanelQueryRunner.ts b/public/app/features/dashboard/state/PanelQueryRunner.ts index 3bbf85800dbb..c6b4e6bd6170 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.ts @@ -11,9 +11,9 @@ import { PanelQueryState } from './PanelQueryState'; import { isSharedDashboardQuery, SharedQueryRunner } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; // Types -import { PanelData, DataQuery, ScopedVars, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui'; +import { PanelData, DataQuery, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui'; -import { TimeRange, DataTransformerConfig, transformDataFrame, toLegacyResponseData } from '@grafana/data'; +import { TimeRange, DataTransformerConfig, transformDataFrame, toLegacyResponseData, ScopedVars } from '@grafana/data'; import config from 'app/core/config'; export interface QueryRunnerOptions< diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index 38eb8848b2ab..bdd5d14d0a99 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -1,6 +1,6 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { FieldDisplay, ScopedVars, DataLinkBuiltInVars } from '@grafana/ui'; -import { LinkModelSupplier, getTimeField } from '@grafana/data'; +import { FieldDisplay, DataLinkBuiltInVars } from '@grafana/ui'; +import { LinkModelSupplier, getTimeField, ScopedVars } from '@grafana/data'; import { getLinkSrv } from './link_srv'; /** diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index d34214efec58..8a94ba2dba75 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -3,8 +3,8 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import templateSrv, { TemplateSrv } from 'app/features/templating/template_srv'; import coreModule from 'app/core/core_module'; import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url'; -import { VariableSuggestion, ScopedVars, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui'; -import { DataLink, KeyValue, deprecationWarning, LinkModel } from '@grafana/data'; +import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui'; +import { DataLink, KeyValue, deprecationWarning, LinkModel, ScopedVars } from '@grafana/data'; export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [ ...templateSrv.variables.map(variable => ({ diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 70f59ad2ecc6..133d2ee9ba70 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -8,7 +8,8 @@ import { importDataSourcePlugin } from './plugin_loader'; import { DataSourceSrv as DataSourceService, getDataSourceSrv as getDataSourceService } from '@grafana/runtime'; // Types -import { DataSourceApi, DataSourceSelectItem, ScopedVars } from '@grafana/ui'; +import { DataSourceApi, DataSourceSelectItem } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; import { auto } from 'angular'; import { TemplateSrv } from '../templating/template_srv'; diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index f43f6449717f..ab358bf8fd41 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -1,8 +1,7 @@ import kbn from 'app/core/utils/kbn'; import _ from 'lodash'; import { variableRegex } from 'app/features/templating/variable'; -import { ScopedVars } from '@grafana/ui'; -import { TimeRange } from '@grafana/data'; +import { TimeRange, ScopedVars } from '@grafana/data'; function luceneEscape(value: string) { return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1'); diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index cc247f2f680a..8cb33f6e5f59 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -1,9 +1,9 @@ import angular, { IQService } from 'angular'; import _ from 'lodash'; -import { dateMath } from '@grafana/data'; +import { dateMath, ScopedVars } from '@grafana/data'; import kbn from 'app/core/utils/kbn'; import { CloudWatchQuery } from './types'; -import { DataSourceApi, DataQueryRequest, DataSourceInstanceSettings, ScopedVars } from '@grafana/ui'; +import { DataSourceApi, DataQueryRequest, DataSourceInstanceSettings } from '@grafana/ui'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index c0aa9ede41b1..301f96d44e04 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -1,11 +1,10 @@ import _ from 'lodash'; -import { dateMath } from '@grafana/data'; +import { dateMath, ScopedVars } from '@grafana/data'; import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version'; import gfunc from './gfunc'; import { IQService } from 'angular'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; -import { ScopedVars } from '@grafana/ui'; export class GraphiteDatasource { basicAuth: string; diff --git a/public/app/plugins/datasource/graphite/graphite_query.ts b/public/app/plugins/datasource/graphite/graphite_query.ts index a149bbbd62ab..759c0fdc67ef 100644 --- a/public/app/plugins/datasource/graphite/graphite_query.ts +++ b/public/app/plugins/datasource/graphite/graphite_query.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { Parser } from './parser'; import { TemplateSrv } from 'app/features/templating/template_srv'; -import { ScopedVars } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; export default class GraphiteQuery { datasource: any; diff --git a/public/app/plugins/datasource/influxdb/influx_query_model.ts b/public/app/plugins/datasource/influxdb/influx_query_model.ts index 5688db238c86..3b573299a589 100644 --- a/public/app/plugins/datasource/influxdb/influx_query_model.ts +++ b/public/app/plugins/datasource/influxdb/influx_query_model.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import queryPart from './query_part'; import kbn from 'app/core/utils/kbn'; import { InfluxQuery, InfluxQueryTag } from './types'; -import { ScopedVars } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; import { TemplateSrv } from 'app/features/templating/template_srv'; export default class InfluxQueryModel { diff --git a/public/app/plugins/datasource/mysql/mysql_query.ts b/public/app/plugins/datasource/mysql/mysql_query.ts index 9ba5b13ecf22..ebea02eb7791 100644 --- a/public/app/plugins/datasource/mysql/mysql_query.ts +++ b/public/app/plugins/datasource/mysql/mysql_query.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import { TemplateSrv } from 'app/features/templating/template_srv'; -import { ScopedVars } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; export default class MysqlQuery { target: any; diff --git a/public/app/plugins/datasource/postgres/postgres_query.ts b/public/app/plugins/datasource/postgres/postgres_query.ts index dc724eb0bc17..033b2ce4f4cd 100644 --- a/public/app/plugins/datasource/postgres/postgres_query.ts +++ b/public/app/plugins/datasource/postgres/postgres_query.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import { TemplateSrv } from 'app/features/templating/template_srv'; -import { ScopedVars } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; export default class PostgresQuery { target: any; diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 02a695830593..a803d717d6a0 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -3,7 +3,8 @@ import appEvents from 'app/core/app_events'; import _ from 'lodash'; import StackdriverMetricFindQuery from './StackdriverMetricFindQuery'; import { StackdriverQuery, MetricDescriptor, StackdriverOptions } from './types'; -import { DataSourceApi, DataQueryRequest, DataSourceInstanceSettings, ScopedVars } from '@grafana/ui'; +import { DataSourceApi, DataQueryRequest, DataSourceInstanceSettings } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; diff --git a/public/app/plugins/panel/table/renderer.ts b/public/app/plugins/panel/table/renderer.ts index 6f11edf152d1..c98d45c5bce3 100644 --- a/public/app/plugins/panel/table/renderer.ts +++ b/public/app/plugins/panel/table/renderer.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, ScopedVars } from '@grafana/ui'; -import { stringToJsRegex } from '@grafana/data'; +import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType } from '@grafana/ui'; +import { stringToJsRegex, ScopedVars } from '@grafana/data'; import { ColumnStyle } from '@grafana/ui/src/components/Table/TableCellBuilder'; import { dateTime } from '@grafana/data'; import { TemplateSrv } from 'app/features/templating/template_srv'; diff --git a/public/app/plugins/panel/table/specs/renderer.test.ts b/public/app/plugins/panel/table/specs/renderer.test.ts index fa024c5f44cd..4bfda6ccad10 100644 --- a/public/app/plugins/panel/table/specs/renderer.test.ts +++ b/public/app/plugins/panel/table/specs/renderer.test.ts @@ -1,7 +1,8 @@ import _ from 'lodash'; import TableModel from 'app/core/table_model'; import { TableRenderer } from '../renderer'; -import { getColorDefinitionByName, ScopedVars } from '@grafana/ui'; +import { getColorDefinitionByName } from '@grafana/ui'; +import { ScopedVars } from '@grafana/data'; describe('when rendering table', () => { const SemiDarkOrange = getColorDefinitionByName('semi-dark-orange'); From e5bf3027fc442f0ba41678de53dc53748d6eed44 Mon Sep 17 00:00:00 2001 From: Kyle Brandt Date: Tue, 10 Sep 2019 15:50:04 -0400 Subject: [PATCH 08/87] Refactor: Move sql_engine to sub package of tsdb (#18991) this way importing the tsdb package does not come with xorm dependencies --- pkg/tsdb/mssql/macros.go | 11 ++-- pkg/tsdb/mssql/mssql.go | 8 ++- pkg/tsdb/mssql/mssql_test.go | 25 ++++---- pkg/tsdb/mysql/macros.go | 11 ++-- pkg/tsdb/mysql/mysql.go | 8 ++- pkg/tsdb/mysql/mysql_test.go | 22 +++---- pkg/tsdb/postgres/macros.go | 11 ++-- pkg/tsdb/postgres/postgres.go | 8 ++- pkg/tsdb/postgres/postgres_test.go | 22 +++---- pkg/tsdb/{ => sqleng}/sql_engine.go | 73 ++++++++++++------------ pkg/tsdb/{ => sqleng}/sql_engine_test.go | 7 ++- 11 files changed, 111 insertions(+), 95 deletions(-) rename pkg/tsdb/{ => sqleng}/sql_engine.go (85%) rename pkg/tsdb/{ => sqleng}/sql_engine_test.go (98%) diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go index c0794863efe5..90a710e428e0 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/macros.go @@ -8,19 +8,20 @@ import ( "github.com/grafana/grafana/pkg/components/gtime" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" ) const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type msSqlMacroEngine struct { - *tsdb.SqlMacroEngineBase + *sqleng.SqlMacroEngineBase timeRange *tsdb.TimeRange query *tsdb.Query } -func newMssqlMacroEngine() tsdb.SqlMacroEngine { - return &msSqlMacroEngine{SqlMacroEngineBase: tsdb.NewSqlMacroEngineBase()} +func newMssqlMacroEngine() sqleng.SqlMacroEngine { + return &msSqlMacroEngine{SqlMacroEngineBase: sqleng.NewSqlMacroEngineBase()} } func (m *msSqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { @@ -80,7 +81,7 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := tsdb.SetupFillmode(m.query, interval, args[2]) + err := sqleng.SetupFillmode(m.query, interval, args[2]) if err != nil { return "", err } @@ -115,7 +116,7 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := tsdb.SetupFillmode(m.query, interval, args[2]) + err := sqleng.SetupFillmode(m.query, interval, args[2]) if err != nil { return "", err } diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 3e0e385d2418..82e15727e2fc 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -3,15 +3,17 @@ package mssql import ( "database/sql" "fmt" - "github.com/grafana/grafana/pkg/setting" "net/url" "strconv" + "github.com/grafana/grafana/pkg/setting" + _ "github.com/denisenkom/go-mssqldb" "github.com/go-xorm/core" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" "github.com/grafana/grafana/pkg/util" ) @@ -27,7 +29,7 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin logger.Debug("getEngine", "connection", cnnstr) } - config := tsdb.SqlQueryEndpointConfiguration{ + config := sqleng.SqlQueryEndpointConfiguration{ DriverName: "mssql", ConnectionString: cnnstr, Datasource: datasource, @@ -38,7 +40,7 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin log: logger, } - return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger) + return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger) } func generateConnectionString(datasource *models.DataSource) string { diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index efc3246a966d..940d26e4d1cd 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" . "github.com/smartystreets/goconvey/convey" ) @@ -58,13 +59,13 @@ func TestMSSQL(t *testing.T) { SkipConvey("MSSQL", t, func() { x := InitMSSQLTestDB(t) - origXormEngine := tsdb.NewXormEngine - tsdb.NewXormEngine = func(d, c string) (*xorm.Engine, error) { + origXormEngine := sqleng.NewXormEngine + sqleng.NewXormEngine = func(d, c string) (*xorm.Engine, error) { return x, nil } - origInterpolate := tsdb.Interpolate - tsdb.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + origInterpolate := sqleng.Interpolate + sqleng.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { return sql, nil } @@ -79,8 +80,8 @@ func TestMSSQL(t *testing.T) { Reset(func() { sess.Close() - tsdb.NewXormEngine = origXormEngine - tsdb.Interpolate = origInterpolate + sqleng.NewXormEngine = origXormEngine + sqleng.Interpolate = origInterpolate }) Convey("Given a table with different native data types", func() { @@ -330,11 +331,11 @@ func TestMSSQL(t *testing.T) { }) Convey("When doing a metric query using timeGroup and $__interval", func() { - mockInterpolate := tsdb.Interpolate - tsdb.Interpolate = origInterpolate + mockInterpolate := sqleng.Interpolate + sqleng.Interpolate = origInterpolate Reset(func() { - tsdb.Interpolate = mockInterpolate + sqleng.Interpolate = mockInterpolate }) Convey("Should replace $__interval", func() { @@ -704,7 +705,7 @@ func TestMSSQL(t *testing.T) { }) Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() { - tsdb.Interpolate = origInterpolate + sqleng.Interpolate = origInterpolate query := &tsdb.TsdbQuery{ TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart), Queries: []*tsdb.Query{ @@ -771,7 +772,7 @@ func TestMSSQL(t *testing.T) { So(err, ShouldBeNil) Convey("When doing a metric query using stored procedure should return correct result", func() { - tsdb.Interpolate = origInterpolate + sqleng.Interpolate = origInterpolate query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -850,7 +851,7 @@ func TestMSSQL(t *testing.T) { So(err, ShouldBeNil) Convey("When doing a metric query using stored procedure should return correct result", func() { - tsdb.Interpolate = origInterpolate + sqleng.Interpolate = origInterpolate query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 8cd83d2f9991..b10d66bab49e 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -7,19 +7,20 @@ import ( "github.com/grafana/grafana/pkg/components/gtime" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" ) const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type mySqlMacroEngine struct { - *tsdb.SqlMacroEngineBase + *sqleng.SqlMacroEngineBase timeRange *tsdb.TimeRange query *tsdb.Query } -func newMysqlMacroEngine() tsdb.SqlMacroEngine { - return &mySqlMacroEngine{SqlMacroEngineBase: tsdb.NewSqlMacroEngineBase()} +func newMysqlMacroEngine() sqleng.SqlMacroEngine { + return &mySqlMacroEngine{SqlMacroEngineBase: sqleng.NewSqlMacroEngineBase()} } func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { @@ -74,7 +75,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := tsdb.SetupFillmode(m.query, interval, args[2]) + err := sqleng.SetupFillmode(m.query, interval, args[2]) if err != nil { return "", err } @@ -109,7 +110,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := tsdb.SetupFillmode(m.query, interval, args[2]) + err := sqleng.SetupFillmode(m.query, interval, args[2]) if err != nil { return "", err } diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 1f569f215db9..1b0c7230c916 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -3,16 +3,18 @@ package mysql import ( "database/sql" "fmt" - "github.com/grafana/grafana/pkg/setting" "reflect" "strconv" "strings" + "github.com/grafana/grafana/pkg/setting" + "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" ) func init() { @@ -49,7 +51,7 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin logger.Debug("getEngine", "connection", cnnstr) } - config := tsdb.SqlQueryEndpointConfiguration{ + config := sqleng.SqlQueryEndpointConfiguration{ DriverName: "mysql", ConnectionString: cnnstr, Datasource: datasource, @@ -61,7 +63,7 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin log: logger, } - return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMysqlMacroEngine(), logger) + return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newMysqlMacroEngine(), logger) } type mysqlRowTransformer struct { diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index e6a1b3a84d2c..7c53b307bb69 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" + . "github.com/smartystreets/goconvey/convey" ) @@ -38,13 +40,13 @@ func TestMySQL(t *testing.T) { Convey("MySQL", t, func() { x := InitMySQLTestDB(t) - origXormEngine := tsdb.NewXormEngine - tsdb.NewXormEngine = func(d, c string) (*xorm.Engine, error) { + origXormEngine := sqleng.NewXormEngine + sqleng.NewXormEngine = func(d, c string) (*xorm.Engine, error) { return x, nil } - origInterpolate := tsdb.Interpolate - tsdb.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + origInterpolate := sqleng.Interpolate + sqleng.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { return sql, nil } @@ -59,8 +61,8 @@ func TestMySQL(t *testing.T) { Reset(func() { sess.Close() - tsdb.NewXormEngine = origXormEngine - tsdb.Interpolate = origInterpolate + sqleng.NewXormEngine = origXormEngine + sqleng.Interpolate = origInterpolate }) Convey("Given a table with different native data types", func() { @@ -303,11 +305,11 @@ func TestMySQL(t *testing.T) { }) Convey("When doing a metric query using timeGroup and $__interval", func() { - mockInterpolate := tsdb.Interpolate - tsdb.Interpolate = origInterpolate + mockInterpolate := sqleng.Interpolate + sqleng.Interpolate = origInterpolate Reset(func() { - tsdb.Interpolate = mockInterpolate + sqleng.Interpolate = mockInterpolate }) Convey("Should replace $__interval", func() { @@ -754,7 +756,7 @@ func TestMySQL(t *testing.T) { }) Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() { - tsdb.Interpolate = origInterpolate + sqleng.Interpolate = origInterpolate query := &tsdb.TsdbQuery{ TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart), Queries: []*tsdb.Query{ diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index f7a194e63cd1..fc1a0a7e3437 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -8,21 +8,22 @@ import ( "github.com/grafana/grafana/pkg/components/gtime" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" ) const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type postgresMacroEngine struct { - *tsdb.SqlMacroEngineBase + *sqleng.SqlMacroEngineBase timeRange *tsdb.TimeRange query *tsdb.Query timescaledb bool } -func newPostgresMacroEngine(timescaledb bool) tsdb.SqlMacroEngine { +func newPostgresMacroEngine(timescaledb bool) sqleng.SqlMacroEngine { return &postgresMacroEngine{ - SqlMacroEngineBase: tsdb.NewSqlMacroEngineBase(), + SqlMacroEngineBase: sqleng.NewSqlMacroEngineBase(), timescaledb: timescaledb, } } @@ -101,7 +102,7 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := tsdb.SetupFillmode(m.query, interval, args[2]) + err := sqleng.SetupFillmode(m.query, interval, args[2]) if err != nil { return "", err } @@ -145,7 +146,7 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := tsdb.SetupFillmode(m.query, interval, args[2]) + err := sqleng.SetupFillmode(m.query, interval, args[2]) if err != nil { return "", err } diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index dc3a006123b3..9836cf16a196 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -2,14 +2,16 @@ package postgres import ( "database/sql" - "github.com/grafana/grafana/pkg/setting" "net/url" "strconv" + "github.com/grafana/grafana/pkg/setting" + "github.com/go-xorm/core" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" ) func init() { @@ -24,7 +26,7 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp logger.Debug("getEngine", "connection", cnnstr) } - config := tsdb.SqlQueryEndpointConfiguration{ + config := sqleng.SqlQueryEndpointConfiguration{ DriverName: "postgres", ConnectionString: cnnstr, Datasource: datasource, @@ -37,7 +39,7 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp timescaledb := datasource.JsonData.Get("timescaledb").MustBool(false) - return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newPostgresMacroEngine(timescaledb), logger) + return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newPostgresMacroEngine(timescaledb), logger) } func generateConnectionString(datasource *models.DataSource) string { diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index c381938aead4..9624c9e53d64 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/sqleng" + _ "github.com/lib/pq" . "github.com/smartystreets/goconvey/convey" ) @@ -39,13 +41,13 @@ func TestPostgres(t *testing.T) { Convey("PostgreSQL", t, func() { x := InitPostgresTestDB(t) - origXormEngine := tsdb.NewXormEngine - tsdb.NewXormEngine = func(d, c string) (*xorm.Engine, error) { + origXormEngine := sqleng.NewXormEngine + sqleng.NewXormEngine = func(d, c string) (*xorm.Engine, error) { return x, nil } - origInterpolate := tsdb.Interpolate - tsdb.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + origInterpolate := sqleng.Interpolate + sqleng.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { return sql, nil } @@ -60,8 +62,8 @@ func TestPostgres(t *testing.T) { Reset(func() { sess.Close() - tsdb.NewXormEngine = origXormEngine - tsdb.Interpolate = origInterpolate + sqleng.NewXormEngine = origXormEngine + sqleng.Interpolate = origInterpolate }) Convey("Given a table with different native data types", func() { @@ -230,11 +232,11 @@ func TestPostgres(t *testing.T) { }) Convey("When doing a metric query using timeGroup and $__interval", func() { - mockInterpolate := tsdb.Interpolate - tsdb.Interpolate = origInterpolate + mockInterpolate := sqleng.Interpolate + sqleng.Interpolate = origInterpolate Reset(func() { - tsdb.Interpolate = mockInterpolate + sqleng.Interpolate = mockInterpolate }) Convey("Should replace $__interval", func() { @@ -686,7 +688,7 @@ func TestPostgres(t *testing.T) { }) Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() { - tsdb.Interpolate = origInterpolate + sqleng.Interpolate = origInterpolate query := &tsdb.TsdbQuery{ TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart), Queries: []*tsdb.Query{ diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go similarity index 85% rename from pkg/tsdb/sql_engine.go rename to pkg/tsdb/sqleng/sql_engine.go index b4ceead85678..d78ac0517543 100644 --- a/pkg/tsdb/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -1,4 +1,4 @@ -package tsdb +package sqleng import ( "container/list" @@ -13,6 +13,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/components/null" @@ -25,12 +26,12 @@ import ( // SqlMacroEngine interpolates macros into sql. It takes in the Query to have access to query context and // timeRange to be able to generate queries that use from and to. type SqlMacroEngine interface { - Interpolate(query *Query, timeRange *TimeRange, sql string) (string, error) + Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) } // SqlTableRowTransformer transforms a query result row to RowValues with proper types. type SqlTableRowTransformer interface { - Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (RowValues, error) + Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) } type engineCacheType struct { @@ -44,7 +45,7 @@ var engineCache = engineCacheType{ versions: make(map[int64]int), } -var sqlIntervalCalculator = NewIntervalCalculator(nil) +var sqlIntervalCalculator = tsdb.NewIntervalCalculator(nil) var NewXormEngine = func(driverName string, connectionString string) (*xorm.Engine, error) { return xorm.NewEngine(driverName, connectionString) @@ -67,7 +68,7 @@ type SqlQueryEndpointConfiguration struct { MetricColumnTypes []string } -var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, rowTransformer SqlTableRowTransformer, macroEngine SqlMacroEngine, log log.Logger) (TsdbQueryEndpoint, error) { +var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, rowTransformer SqlTableRowTransformer, macroEngine SqlMacroEngine, log log.Logger) (tsdb.TsdbQueryEndpoint, error) { queryEndpoint := sqlQueryEndpoint{ rowTransformer: rowTransformer, macroEngine: macroEngine, @@ -115,9 +116,9 @@ var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, rowTransfo const rowLimit = 1000000 // Query is the main function for the SqlQueryEndpoint -func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *TsdbQuery) (*Response, error) { - result := &Response{ - Results: make(map[string]*QueryResult), +func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { + result := &tsdb.Response{ + Results: make(map[string]*tsdb.QueryResult), } var wg sync.WaitGroup @@ -128,7 +129,7 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, continue } - queryResult := &QueryResult{Meta: simplejson.New(), RefId: query.RefId} + queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefId} result.Results[query.RefId] = queryResult // global substitutions @@ -149,7 +150,7 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, wg.Add(1) - go func(rawSQL string, query *Query, queryResult *QueryResult) { + go func(rawSQL string, query *tsdb.Query, queryResult *tsdb.QueryResult) { defer wg.Done() session := e.engine.NewSession() defer session.Close() @@ -187,8 +188,8 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, } // global macros/substitutions for all sql datasources -var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string, error) { - minInterval, err := GetIntervalFrom(query.DataSource, query.Model, time.Second*60) +var Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + minInterval, err := tsdb.GetIntervalFrom(query.DataSource, query.Model, time.Second*60) if err != nil { return sql, nil } @@ -202,7 +203,7 @@ var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string, return sql, nil } -func (e *sqlQueryEndpoint) transformToTable(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error { +func (e *sqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { columnNames, err := rows.Columns() columnCount := len(columnNames) @@ -213,9 +214,9 @@ func (e *sqlQueryEndpoint) transformToTable(query *Query, rows *core.Rows, resul rowCount := 0 timeIndex := -1 - table := &Table{ - Columns: make([]TableColumn, columnCount), - Rows: make([]RowValues, 0), + table := &tsdb.Table{ + Columns: make([]tsdb.TableColumn, columnCount), + Rows: make([]tsdb.RowValues, 0), } for i, name := range columnNames { @@ -256,8 +257,8 @@ func (e *sqlQueryEndpoint) transformToTable(query *Query, rows *core.Rows, resul return nil } -func (e *sqlQueryEndpoint) transformToTimeSeries(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error { - pointsBySeries := make(map[string]*TimeSeries) +func (e *sqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { + pointsBySeries := make(map[string]*tsdb.TimeSeries) seriesByQueryOrder := list.New() columnNames, err := rows.Columns() @@ -385,7 +386,7 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *Query, rows *core.Rows, series, exist := pointsBySeries[metric] if !exist { - series = &TimeSeries{Name: metric} + series = &tsdb.TimeSeries{Name: metric} pointsBySeries[metric] = series seriesByQueryOrder.PushBack(metric) } @@ -410,12 +411,12 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *Query, rows *core.Rows, intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval for i := intervalStart; i < timestamp; i += fillInterval { - series.Points = append(series.Points, TimePoint{fillValue, null.FloatFrom(i)}) + series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) rowCount++ } } - series.Points = append(series.Points, TimePoint{value, null.FloatFrom(timestamp)}) + series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) } @@ -442,7 +443,7 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *Query, rows *core.Rows, // align interval start intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval for i := intervalStart + fillInterval; i < intervalEnd; i += fillInterval { - series.Points = append(series.Points, TimePoint{fillValue, null.FloatFrom(i)}) + series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) rowCount++ } } @@ -454,7 +455,7 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *Query, rows *core.Rows, // ConvertSqlTimeColumnToEpochMs converts column named time to unix timestamp in milliseconds // to make native datetime types and epoch dates work in annotation and table queries. -func ConvertSqlTimeColumnToEpochMs(values RowValues, timeIndex int) { +func ConvertSqlTimeColumnToEpochMs(values tsdb.RowValues, timeIndex int) { if timeIndex >= 0 { switch value := values[timeIndex].(type) { case time.Time: @@ -464,40 +465,40 @@ func ConvertSqlTimeColumnToEpochMs(values RowValues, timeIndex int) { values[timeIndex] = float64((*value).UnixNano()) / float64(time.Millisecond) } case int64: - values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(value))) case *int64: if value != nil { - values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(*value))) } case uint64: - values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(value))) case *uint64: if value != nil { - values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(*value))) } case int32: - values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(value))) case *int32: if value != nil { - values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(*value))) } case uint32: - values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(value))) case *uint32: if value != nil { - values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + values[timeIndex] = int64(tsdb.EpochPrecisionToMs(float64(*value))) } case float64: - values[timeIndex] = EpochPrecisionToMs(value) + values[timeIndex] = tsdb.EpochPrecisionToMs(value) case *float64: if value != nil { - values[timeIndex] = EpochPrecisionToMs(*value) + values[timeIndex] = tsdb.EpochPrecisionToMs(*value) } case float32: - values[timeIndex] = EpochPrecisionToMs(float64(value)) + values[timeIndex] = tsdb.EpochPrecisionToMs(float64(value)) case *float32: if value != nil { - values[timeIndex] = EpochPrecisionToMs(float64(*value)) + values[timeIndex] = tsdb.EpochPrecisionToMs(float64(*value)) } } } @@ -609,7 +610,7 @@ func ConvertSqlValueColumnToFloat(columnName string, columnValue interface{}) (n return value, nil } -func SetupFillmode(query *Query, interval time.Duration, fillmode string) error { +func SetupFillmode(query *tsdb.Query, interval time.Duration, fillmode string) error { query.Model.Set("fill", true) query.Model.Set("fillInterval", interval.Seconds()) switch fillmode { diff --git a/pkg/tsdb/sql_engine_test.go b/pkg/tsdb/sqleng/sql_engine_test.go similarity index 98% rename from pkg/tsdb/sql_engine_test.go rename to pkg/tsdb/sqleng/sql_engine_test.go index bc77a95734d4..4cdf700ea980 100644 --- a/pkg/tsdb/sql_engine_test.go +++ b/pkg/tsdb/sqleng/sql_engine_test.go @@ -1,4 +1,4 @@ -package tsdb +package sqleng import ( "fmt" @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" ) @@ -20,8 +21,8 @@ func TestSqlEngine(t *testing.T) { Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) to := from.Add(5 * time.Minute) - timeRange := NewFakeTimeRange("5m", "now", to) - query := &Query{DataSource: &models.DataSource{}, Model: simplejson.New()} + timeRange := tsdb.NewFakeTimeRange("5m", "now", to) + query := &tsdb.Query{DataSource: &models.DataSource{}, Model: simplejson.New()} Convey("interpolate $__interval", func() { sql, err := Interpolate(query, timeRange, "select $__interval ") From 4c0e5b14f50335117070c4ccd666f309776c3fd5 Mon Sep 17 00:00:00 2001 From: lzd Date: Wed, 11 Sep 2019 13:42:28 +0800 Subject: [PATCH 09/87] Piechart: fix unit selector when scrolling is required (#18932) --- public/app/core/directives/dropdown_typeahead.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/public/app/core/directives/dropdown_typeahead.ts b/public/app/core/directives/dropdown_typeahead.ts index 05e2eb49ec56..9f201841551b 100644 --- a/public/app/core/directives/dropdown_typeahead.ts +++ b/public/app/core/directives/dropdown_typeahead.ts @@ -59,6 +59,14 @@ export function dropdownTypeahead($compile: any) { [] ); + const closeDropdownMenu = () => { + $input.hide(); + $input.val(''); + $button.show(); + $button.focus(); + elem.removeClass('open'); + }; + $scope.menuItemSelected = (index: number, subIndex: number) => { const menuItem = $scope.menuItems[index]; const payload: any = { $item: menuItem }; @@ -66,6 +74,7 @@ export function dropdownTypeahead($compile: any) { payload.$subItem = menuItem.submenu[subIndex]; } $scope.dropdownTypeaheadOnSelect(payload); + closeDropdownMenu(); }; $input.attr('data-provide', 'typeahead'); @@ -105,6 +114,10 @@ export function dropdownTypeahead($compile: any) { elem.toggleClass('open', $input.val() === ''); }); + elem.mousedown((evt: Event) => { + evt.preventDefault(); + }); + $input.blur(() => { $input.hide(); $input.val(''); From 7b856ae040cf8ea36d8ed761fc95c3cb07cdf82c Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 10 Sep 2019 22:45:08 -0700 Subject: [PATCH 10/87] QueryOptions: update maxDataPoints text and show any value that is configured (#18761) * update maxDataPoints UI * use maxDataPoints to calculate interval * don't change interval calculation --- .../dashboard/panel_editor/QueryOptions.tsx | 39 +++++++++++-------- .../datasource/testdata/StreamHandler.ts | 8 ++-- .../plugins/datasource/testdata/plugin.json | 3 +- .../app/plugins/datasource/testdata/types.ts | 3 +- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/public/app/features/dashboard/panel_editor/QueryOptions.tsx b/public/app/features/dashboard/panel_editor/QueryOptions.tsx index 512b28571775..c00990226505 100644 --- a/public/app/features/dashboard/panel_editor/QueryOptions.tsx +++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx @@ -71,7 +71,8 @@ export class QueryOptions extends PureComponent { tooltipInfo: ( <> The maximum data points the query should return. For graphs this is automatically set to one data point per - pixel. For some data sources this can also be capped in the datasource settings page. + pixel. For some data sources this can also be capped in the datasource settings page. With streaming data, + this value is used for the rolling buffer. ), }, @@ -156,27 +157,31 @@ export class QueryOptions extends PureComponent { this.setState({ ...this.state, [panelKey]: event.target.value }); }; + /** + * Show options for any value that is set, or values that the + * current datasource says it will use + */ renderOptions = () => { const { datasource } = this.props; - const { queryOptions } = datasource.meta; + const queryOptions: any = datasource.meta.queryOptions || {}; - if (!queryOptions) { - return null; - } - - return Object.keys(queryOptions).map(key => { + return Object.keys(this.allOptions).map(key => { const options = this.allOptions[key]; const panelKey = options.panelKey || key; - return ( - - ); + // @ts-ignore + const value = this.state[panelKey]; + if (value || queryOptions[key]) { + return ( + + ); + } + return null; // nothing to render }); }; diff --git a/public/app/plugins/datasource/testdata/StreamHandler.ts b/public/app/plugins/datasource/testdata/StreamHandler.ts index 4997586c2db9..2d01f3ef4b8e 100644 --- a/public/app/plugins/datasource/testdata/StreamHandler.ts +++ b/public/app/plugins/datasource/testdata/StreamHandler.ts @@ -175,9 +175,9 @@ export class SignalWorker extends StreamWorker { }; initBuffer(refId: string) { - const { speed, buffer } = this.query; + const { speed } = this.query; const request = this.stream.request; - const maxRows = buffer ? buffer : request.maxDataPoints; + const maxRows = request.maxDataPoints || 1000; const times = new CircularVector({ capacity: maxRows }); const vals = new CircularVector({ capacity: maxRows }); this.values = [times, vals]; @@ -341,11 +341,11 @@ export class LogsWorker extends StreamWorker { }; initBuffer(refId: string) { - const { speed, buffer } = this.query; + const { speed } = this.query; const request = this.stream.request; - const maxRows = buffer ? buffer : request.maxDataPoints; + const maxRows = request.maxDataPoints || 1000; const times = new CircularVector({ capacity: maxRows }); const lines = new CircularVector({ capacity: maxRows }); diff --git a/public/app/plugins/datasource/testdata/plugin.json b/public/app/plugins/datasource/testdata/plugin.json index f34498957be2..522ee7ebdd97 100644 --- a/public/app/plugins/datasource/testdata/plugin.json +++ b/public/app/plugins/datasource/testdata/plugin.json @@ -9,7 +9,8 @@ "annotations": true, "queryOptions": { - "minInterval": true + "minInterval": true, + "maxDataPoints": true }, "info": { diff --git a/public/app/plugins/datasource/testdata/types.ts b/public/app/plugins/datasource/testdata/types.ts index 7b4586555907..47ed6c24b50a 100644 --- a/public/app/plugins/datasource/testdata/types.ts +++ b/public/app/plugins/datasource/testdata/types.ts @@ -18,7 +18,6 @@ export interface StreamingQuery { speed: number; spread: number; noise: number; // wiggle around the signal for min/max - bands?: number; // number of bands around the middle van - buffer?: number; + bands?: number; // number of bands around the middle band url?: string; // the Fetch URL } From 196f8503a8e5e7724ebaed5dc098e78267048a8b Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Wed, 11 Sep 2019 09:00:14 +0200 Subject: [PATCH 11/87] grafana/ui: Add Time of day picker (#18894) * Adding DashboardPicker component * fix prop names * fix prop names pt2 * add component and modify utils * add showHour prop * add minuteStep to TimeOfDayPicker, add value to DashboardPicker * fix for dashboard picker, missed adding file * Adding story * add another story for hiding hour and style fixes * fix these generated files * fixes after review * rename current value * fix type issue on onChange * fix story --- .../grafana-data/src/utils/moment_wrapper.ts | 8 ++- packages/grafana-ui/package.json | 1 + .../TimePicker/TimeOfDayPicker.story.tsx | 53 +++++++++++++++++++ .../components/TimePicker/TimeOfDayPicker.tsx | 26 +++++++++ .../TimePicker/TimePicker.story.tsx | 4 +- .../TimePicker/_TimeOfDayPicker.scss | 39 ++++++++++++++ packages/grafana-ui/src/components/index.scss | 1 + packages/grafana-ui/src/components/index.ts | 1 + .../utils/storybook/withRightAlignedStory.tsx | 2 +- .../components/Select/DashboardPicker.tsx | 7 ++- .../sass/_variables.dark.generated.scss.rej | 6 +++ .../sass/_variables.light.generated.scss.rej | 6 +++ yarn.lock | 16 +++++- 13 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.story.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/_TimeOfDayPicker.scss create mode 100644 public/sass/_variables.dark.generated.scss.rej create mode 100644 public/sass/_variables.light.generated.scss.rej diff --git a/packages/grafana-data/src/utils/moment_wrapper.ts b/packages/grafana-data/src/utils/moment_wrapper.ts index 814db6fe4809..32e58f80ca5a 100644 --- a/packages/grafana-data/src/utils/moment_wrapper.ts +++ b/packages/grafana-data/src/utils/moment_wrapper.ts @@ -1,7 +1,6 @@ import { TimeZone } from '../types/time'; /* tslint:disable:import-blacklist ban ban-types */ -import moment, { MomentInput, DurationInputArg1 } from 'moment'; - +import moment, { Moment, MomentInput, DurationInputArg1 } from 'moment'; export interface DateTimeBuiltinFormat { __momentBuiltinFormatBrand: any; } @@ -72,6 +71,7 @@ export interface DateTime extends Object { utc: () => DateTime; utcOffset: () => number; hour?: () => number; + minute?: () => number; } export const setLocale = (language: string) => { @@ -98,6 +98,10 @@ export const dateTime = (input?: DateTimeInput, formatInput?: FormatInput): Date return moment(input as MomentInput, formatInput) as DateTime; }; +export const dateTimeAsMoment = (input?: DateTimeInput) => { + return dateTime(input) as Moment; +}; + export const dateTimeForTimeZone = ( timezone?: TimeZone, input?: DateTimeInput, diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 826b9e0aca2f..63de94851a0a 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -34,6 +34,7 @@ "lodash": "4.17.14", "moment": "2.24.0", "papaparse": "4.6.3", + "rc-time-picker": "^3.7.2", "react": "16.8.6", "react-calendar": "2.18.1", "react-color": "2.17.0", diff --git a/packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.story.tsx new file mode 100644 index 000000000000..8697782e5284 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.story.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { TimeOfDayPicker } from './TimeOfDayPicker'; +import { UseState } from '../../utils/storybook/UseState'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { dateTime } from '@grafana/data'; + +const TimeOfDayPickerStories = storiesOf('UI/TimeOfDayPicker', module); + +TimeOfDayPickerStories.addDecorator(withCenteredStory); + +TimeOfDayPickerStories.add('default', () => { + return ( + + {(value, updateValue) => { + return ( + { + action('on selected')(newValue); + updateValue({ value: newValue }); + }} + value={value.value} + /> + ); + }} + + ); +}); + +TimeOfDayPickerStories.add('only minutes', () => { + return ( + + {(value, updateValue) => { + return ( + { + action('on selected')(newValue); + updateValue({ value: newValue }); + }} + value={value.value} + showHour={false} + /> + ); + }} + + ); +}); diff --git a/packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.tsx b/packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.tsx new file mode 100644 index 000000000000..fdd8130d97b7 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; +import RcTimePicker from 'rc-time-picker'; +import { dateTime, DateTime, dateTimeAsMoment } from '@grafana/data'; + +interface Props { + onChange: (value: DateTime) => void; + value: DateTime; + showHour?: boolean; + minuteStep?: number; +} + +export const TimeOfDayPicker: FC = ({ minuteStep = 1, showHour = true, onChange, value }) => { + return ( +
+ onChange(dateTime(value))} + allowEmpty={false} + showSecond={false} + value={dateTimeAsMoment(value)} + showHour={showHour} + minuteStep={minuteStep} + /> +
+ ); +}; diff --git a/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx index 4c2123b9c4d9..59f6c97a8575 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx @@ -4,12 +4,12 @@ import { action } from '@storybook/addon-actions'; import { TimePicker } from './TimePicker'; import { UseState } from '../../utils/storybook/UseState'; -import { withRighAlignedStory } from '../../utils/storybook/withRightAlignedStory'; +import { withRightAlignedStory } from '../../utils/storybook/withRightAlignedStory'; import { TimeFragment, dateTime } from '@grafana/data'; const TimePickerStories = storiesOf('UI/TimePicker', module); -TimePickerStories.addDecorator(withRighAlignedStory); +TimePickerStories.addDecorator(withRightAlignedStory); TimePickerStories.add('default', () => { return ( diff --git a/packages/grafana-ui/src/components/TimePicker/_TimeOfDayPicker.scss b/packages/grafana-ui/src/components/TimePicker/_TimeOfDayPicker.scss new file mode 100644 index 000000000000..6dbefe4a5318 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/_TimeOfDayPicker.scss @@ -0,0 +1,39 @@ +@import '../../node_modules/rc-time-picker/assets/index.css'; + +.rc-time-picker-input, +.rc-time-picker-panel-input-wrap, +.rc-time-picker-panel-inner { + background-color: $input-bg; + color: $input-color; + border-color: $input-border-color; + font-size: $font-size-base; +} + +.rc-time-picker-input { + padding: $input-padding; + height: $input-height; +} + +.rc-time-picker-panel { + width: 176px; +} + +.rc-time-picker-panel-select { + width: 50%; + + &:only-child { + width: 100%; + } + + .rc-time-picker-panel-select-option-selected { + background-color: $menu-dropdown-hover-bg; + } + + li:hover { + background-color: $menu-dropdown-hover-bg; + } +} + +.rc-time-picker-panel-narrow { + max-width: none; +} diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss index f9af38c877e6..0a9adb3650e5 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -14,3 +14,4 @@ @import 'BarGauge/BarGauge'; @import 'RefreshPicker/RefreshPicker'; @import 'TimePicker/TimePicker'; +@import 'TimePicker/TimeOfDayPicker'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b730bd2fbe6c..c47bb3a10d6f 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -35,6 +35,7 @@ export { StatsPicker } from './StatsPicker/StatsPicker'; export { Input, InputStatus } from './Input/Input'; export { RefreshPicker } from './RefreshPicker/RefreshPicker'; export { TimePicker } from './TimePicker/TimePicker'; +export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker'; export { List } from './List/List'; // Renderless diff --git a/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx b/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx index 85c024d0249f..3fca1a7a7187 100644 --- a/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx +++ b/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx @@ -17,4 +17,4 @@ const RightAlignedStory: React.FunctionComponent<{}> = ({ children }) => { ); }; -export const withRighAlignedStory = (story: RenderFunction) => {story()}; +export const withRightAlignedStory = (story: RenderFunction) => {story()}; diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index ffb4eece11c5..924f41417516 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -1,5 +1,6 @@ import React, { PureComponent } from 'react'; import { AsyncSelect } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; import { debounce } from 'lodash'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { DashboardSearchHit, DashboardDTO } from 'app/types'; @@ -7,6 +8,7 @@ import { DashboardSearchHit, DashboardDTO } from 'app/types'; export interface Props { className?: string; onSelected: (dashboard: DashboardDTO) => void; + currentDashboardId: SelectableValue; } export interface State { @@ -36,7 +38,7 @@ export class DashboardPicker extends PureComponent { .then((result: DashboardSearchHit[]) => { const dashboards = result.map((item: DashboardSearchHit) => { return { - id: item.uid, + id: item.id, value: item.id, label: `${item.folderTitle ? item.folderTitle : 'General'}/${item.title}`, }; @@ -48,7 +50,7 @@ export class DashboardPicker extends PureComponent { }; render() { - const { className, onSelected } = this.props; + const { className, onSelected, currentDashboardId } = this.props; const { isLoading } = this.state; return ( @@ -63,6 +65,7 @@ export class DashboardPicker extends PureComponent { onChange={onSelected} placeholder="Select dashboard" noOptionsMessage={() => 'No dashboards found'} + value={currentDashboardId} /> diff --git a/public/sass/_variables.dark.generated.scss.rej b/public/sass/_variables.dark.generated.scss.rej new file mode 100644 index 000000000000..100525b5ed61 --- /dev/null +++ b/public/sass/_variables.dark.generated.scss.rej @@ -0,0 +1,6 @@ +diff a/public/sass/_variables.dark.generated.scss b/public/sass/_variables.dark.generated.scss (rejected hunks) +@@ -94,2 +94,2 @@ $textShadow: none; +-$brand-gradient-horizontal: linear-gradient(to right, #f05a28 30%, #fbca0a 99%); +-$brand-gradient-vertical: linear-gradient(#f05a28 30%, #fbca0a 99%); ++$brand-gradient-horizontal: linear-gradient(to right, #F05A28 30%, #FBCA0A 99%); ++$brand-gradient-vertical: linear-gradient(#F05A28 30%, #FBCA0A 99%); diff --git a/public/sass/_variables.light.generated.scss.rej b/public/sass/_variables.light.generated.scss.rej new file mode 100644 index 000000000000..e16225be503e --- /dev/null +++ b/public/sass/_variables.light.generated.scss.rej @@ -0,0 +1,6 @@ +diff a/public/sass/_variables.light.generated.scss b/public/sass/_variables.light.generated.scss (rejected hunks) +@@ -86,2 +86,2 @@ $text-shadow-faint: none; +-$brand-gradient-horizontal: linear-gradient(to right, #f05a28 30%, #fbca0a 99%); +-$brand-gradient-vertical: linear-gradient(#f05a28 30%, #fbca0a 99%); ++$brand-gradient-horizontal: linear-gradient(to right, #F05A28 30%, #FBCA0A 99%); ++$brand-gradient-vertical: linear-gradient(#F05A28 30%, #FBCA0A 99%); diff --git a/yarn.lock b/yarn.lock index 17726f040e4f..1a4fa4be6538 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12068,7 +12068,7 @@ module-alias@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.0.tgz#a2e32275381642252bf0c51405f7a09a367479b5" -moment@2.24.0: +moment@2.24.0, moment@2.x: version "2.24.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" @@ -14629,7 +14629,7 @@ qw@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/qw/-/qw-1.0.1.tgz#efbfdc740f9ad054304426acb183412cc8b996d4" -raf@^3.1.0, raf@^3.4.0: +raf@^3.1.0, raf@^3.4.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" dependencies: @@ -14722,6 +14722,18 @@ rc-cascader@0.14.0: shallow-equal "^1.0.0" warning "^4.0.1" +rc-time-picker@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/rc-time-picker/-/rc-time-picker-3.7.2.tgz#fabe5501adf1374d31a2d3b47f1ba89fc2dc2467" + integrity sha512-UVWO9HXGyZoM4I2THlJsEAFcZQz+tYwdcpoHXCEFZsRLz9L2+7vV4EMp9Wa3UrtzMFEt83qSAX/90dCJeKl9sg== + dependencies: + classnames "2.x" + moment "2.x" + prop-types "^15.5.8" + raf "^3.4.1" + rc-trigger "^2.2.0" + react-lifecycles-compat "^3.0.4" + rc-trigger@^2.2.0: version "2.6.2" resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-2.6.2.tgz#a9c09ba5fad63af3b2ec46349c7db6cb46657001" From f2ca3abf07e9c56e30bba88eebe6ec61a4d08229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 11 Sep 2019 09:02:17 +0200 Subject: [PATCH 12/87] AlphaNotice: replaced big popover tooltip with native tooltip (#18997) --- .../components/AlphaNotice/AlphaNotice.tsx | 19 +++++-------------- .../dashboard/panel_editor/QueriesTab.tsx | 2 +- .../dashboard/panel_editor/QueryOptions.tsx | 4 +++- .../app/features/plugins/PluginStateInfo.tsx | 17 +++-------------- 4 files changed, 12 insertions(+), 30 deletions(-) diff --git a/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx index 5951c5446d85..a13efabc4c2f 100644 --- a/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx +++ b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx @@ -1,22 +1,15 @@ import React, { FC, useContext } from 'react'; import { css, cx } from 'emotion'; import { PluginState, ThemeContext } from '../../index'; -import { Tooltip } from '../index'; interface Props { state?: PluginState; - text?: JSX.Element; + text?: string; className?: string; } export const AlphaNotice: FC = ({ state, text, className }) => { - const tooltipContent = text || ( -
-
Alpha Feature
-

This feature is a work in progress and updates may include breaking changes.

-
- ); - + const tooltipContent = text || 'This feature is a work in progress and updates may include breaking changes'; const theme = useContext(ThemeContext); const styles = cx( @@ -35,10 +28,8 @@ export const AlphaNotice: FC = ({ state, text, className }) => { ); return ( - -
- {state} -
-
+
+ {state} +
); }; diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index dacdbcea94ba..98d68f3d90b3 100644 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx @@ -287,7 +287,7 @@ export class QueriesTab extends PureComponent { - Transform query results + Query results { return Object.keys(this.allOptions).map(key => { const options = this.allOptions[key]; const panelKey = options.panelKey || key; + // @ts-ignore const value = this.state[panelKey]; - if (value || queryOptions[key]) { + + if (queryOptions[key]) { return ( -
Alpha Plugin
-

This plugin is a work in progress and updates may include breaking changes.

- - ); - + return 'Alpha Plugin: This plugin is a work in progress and updates may include breaking changes'; case PluginState.beta: - return ( -
-
Beta Plugin
-

There could be bugs and minor breaking changes to this plugin.

-
- ); + return 'Beta Plugin: There could be bugs and minor breaking changes to this plugin'; } return null; } From 7ca77b8a985d2f491627029ca954d0d0bfb12f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 11 Sep 2019 09:02:33 +0200 Subject: [PATCH 13/87] Calcs: Fixed calc reducer (#18998) --- packages/grafana-data/src/utils/fieldReducer.test.ts | 8 ++------ packages/grafana-data/src/utils/fieldReducer.ts | 4 ++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/grafana-data/src/utils/fieldReducer.test.ts b/packages/grafana-data/src/utils/fieldReducer.test.ts index 5328993bba9c..c4bb1f1e4149 100644 --- a/packages/grafana-data/src/utils/fieldReducer.test.ts +++ b/packages/grafana-data/src/utils/fieldReducer.test.ts @@ -52,17 +52,13 @@ describe('Stats Calculators', () => { it('should calculate basic stats', () => { const stats = reduceField({ field: basicTable.fields[0], - reducers: ['first', 'last', 'mean'], + reducers: ['first', 'last', 'mean', 'count'], }); - // First expect(stats.first).toEqual(10); - - // Last expect(stats.last).toEqual(20); - - // Mean expect(stats.mean).toEqual(15); + expect(stats.count).toEqual(2); }); it('should support a single stat also', () => { diff --git a/packages/grafana-data/src/utils/fieldReducer.ts b/packages/grafana-data/src/utils/fieldReducer.ts index ad598d6a8ac0..428968f4043e 100644 --- a/packages/grafana-data/src/utils/fieldReducer.ts +++ b/packages/grafana-data/src/utils/fieldReducer.ts @@ -247,13 +247,17 @@ function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean // Just used for calcutations -- not exposed as a stat previousDeltaUp: true, } as FieldCalcs; + const data = field.values; + calcs.count = data.length; for (let i = 0; i < data.length; i++) { let currentValue = data.get(i); + if (i === 0) { calcs.first = currentValue; } + calcs.last = currentValue; if (currentValue === null) { From a07f46cee0091d8a737069dffd1a80bc44b664ff Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Wed, 11 Sep 2019 11:35:09 +0200 Subject: [PATCH 14/87] PageContent: fix logic in Page.Contents (#19002) --- public/app/core/components/Page/PageContents.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx index 0db32a1ff01b..1710a32caa15 100644 --- a/public/app/core/components/Page/PageContents.tsx +++ b/public/app/core/components/Page/PageContents.tsx @@ -13,12 +13,7 @@ class PageContents extends Component { render() { const { isLoading } = this.props; - return ( -
- {isLoading && } - {this.props.children} -
- ); + return
{isLoading ? : this.props.children}
; } } From fa007423e331ab92d2d5489fd0f721afe59a3b7a Mon Sep 17 00:00:00 2001 From: gotjosh Date: Wed, 11 Sep 2019 13:43:05 +0100 Subject: [PATCH 15/87] API: Add `updatedAt` to api/users/:id (#19004) * API: Add `updatedAt` to api/users/:id This adds the timestamp of when a particular user was last updated to the `api/users/:id` endpoint. This helps our administrators understand when was the user information last updated. Particularly when it comes from external systems e.g. LDAP --- pkg/api/user_test.go | 55 +++++++++++++++++++++++++++++++++-- pkg/models/user.go | 21 ++++++------- pkg/services/sqlstore/user.go | 1 + 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 6aa9dd9adbf6..c38bb498c9d9 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -1,13 +1,15 @@ package api import ( + "net/http" "testing" - - "github.com/grafana/grafana/pkg/models" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestUserApiEndpoint(t *testing.T) { @@ -20,6 +22,55 @@ func TestUserApiEndpoint(t *testing.T) { TotalCount: 2, } + loggedInUserScenario("When calling GET on", "api/users/:id", func(sc *scenarioContext) { + fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC) + bus.AddHandler("test", func(query *models.GetUserProfileQuery) error { + query.Result = models.UserProfileDTO{ + Id: int64(1), + Email: "daniel@grafana.com", + Name: "Daniel", + Login: "danlee", + OrgId: int64(2), + IsGrafanaAdmin: true, + IsDisabled: false, + IsExternal: false, + UpdatedAt: fakeNow, + } + return nil + }) + + bus.AddHandler("test", func(query *models.GetAuthInfoQuery) error { + query.Result = &models.UserAuth{ + AuthModule: models.AuthModuleLDAP, + } + return nil + }) + + sc.handlerFunc = GetUserByID + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + expected := ` + { + "id": 1, + "email": "daniel@grafana.com", + "name": "Daniel", + "login": "danlee", + "theme": "", + "orgId": 2, + "isGrafanaAdmin": true, + "isDisabled": false, + "isExternal": true, + "authLabels": [ + "LDAP" + ], + "updatedAt": "2019-02-11T17:30:40Z" + } + ` + + require.Equal(t, http.StatusOK, sc.resp.Code) + require.JSONEq(t, expected, sc.resp.Body.String()) + }) + loggedInUserScenario("When calling GET on", "/api/users", func(sc *scenarioContext) { var sentLimit int var sendPage int diff --git a/pkg/models/user.go b/pkg/models/user.go index 491d5e32d5ce..c2d10b74778d 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -215,16 +215,17 @@ func (user *SignedInUser) IsRealUser() bool { } type UserProfileDTO struct { - Id int64 `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Login string `json:"login"` - Theme string `json:"theme"` - OrgId int64 `json:"orgId"` - IsGrafanaAdmin bool `json:"isGrafanaAdmin"` - IsDisabled bool `json:"isDisabled"` - IsExternal bool `json:"isExternal"` - AuthLabels []string `json:"authLabels"` + Id int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Login string `json:"login"` + Theme string `json:"theme"` + OrgId int64 `json:"orgId"` + IsGrafanaAdmin bool `json:"isGrafanaAdmin"` + IsDisabled bool `json:"isDisabled"` + IsExternal bool `json:"isExternal"` + AuthLabels []string `json:"authLabels"` + UpdatedAt time.Time `json:"updatedAt"` } type UserSearchHitDTO struct { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 659c2dd864d8..f7c665b9c27c 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -331,6 +331,7 @@ func GetUserProfile(query *models.GetUserProfileQuery) error { IsGrafanaAdmin: user.IsAdmin, IsDisabled: user.IsDisabled, OrgId: user.OrgId, + UpdatedAt: user.Updated, } return err From c2dea633e60e85b267829e5893df49b4c9afdb73 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 12 Sep 2019 08:00:31 +0200 Subject: [PATCH 16/87] Packages: update versioning and release process (#18195) --- .circleci/config.yml | 28 +++++++++++++++ lerna.json | 2 +- package.json | 6 ++-- packages/README.md | 47 +++++++++++++++++++++---- packages/grafana-data/README.md | 4 ++- packages/grafana-data/package.json | 2 +- packages/grafana-runtime/README.md | 4 ++- packages/grafana-runtime/package.json | 2 +- packages/grafana-toolkit/README.md | 9 +++-- packages/grafana-toolkit/package.json | 2 +- packages/grafana-ui/README.md | 2 ++ packages/grafana-ui/package.json | 2 +- scripts/build/release-packages.sh | 40 +++++++++++++++++++++ scripts/circle-release-next-packages.sh | 12 ++++--- 14 files changed, 140 insertions(+), 22 deletions(-) create mode 100755 scripts/build/release-packages.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index c03c91febfb6..be507171f9a0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -672,6 +672,21 @@ jobs: name: Release next packages command: './scripts/circle-release-next-packages.sh' + release-packages: + docker: + - image: circleci/node:10 + steps: + - checkout + - run: + name: Boostrap lerna + command: 'npx lerna bootstrap' + - run: + name: npm - Prepare auth token + command: 'echo //registry.npmjs.org/:_authToken=$NPM_TOKEN >> ~/.npmrc' + - run: + name: Release packages + command: ./scripts/build/release-packages.sh "${CIRCLE_TAG}" + workflows: version: 2 build-master: @@ -754,6 +769,7 @@ workflows: - build-all - test-frontend filters: *filter-only-master + release: jobs: - build-all: @@ -810,6 +826,18 @@ workflows: - mysql-integration-test - postgres-integration-test filters: *filter-only-release + - release-packages: + requires: + - build-all + - test-backend + - test-frontend + - codespell + - lint-go + - shellcheck + - mysql-integration-test + - postgres-integration-test + - build-oss-msi + filters: *filter-only-release - build-oss-msi: requires: - build-all diff --git a/lerna.json b/lerna.json index 28322a3f6273..30f4e4728fd6 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "npmClient": "yarn", "useWorkspaces": true, "packages": ["packages/*"], - "version": "6.4.0-alpha.44" + "version": "6.4.0-pre" } diff --git a/package.json b/package.json index 4ac25982f42c..a16e5a327326 100644 --- a/package.json +++ b/package.json @@ -163,10 +163,12 @@ "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write", "precommit": "grafana-toolkit precommit", "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", - "packages:prepare": "lerna run clean && npm run test && lerna version --tag-version-prefix=\"packages@\" -m \"Packages: publish %s\" --no-push", + "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", "packages:build": "lerna run clean && lerna run build", "packages:publish": "lerna publish from-package --contents dist", - "packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes" + "packages:publishLatest": "lerna publish from-package --contents dist --yes", + "packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes", + "packages:publishCanary": "lerna publish from-package --contents dist --dist-tag canary --yes" }, "husky": { "hooks": { diff --git a/packages/README.md b/packages/README.md index 992a72c4f51a..aa4c4a1b7ec3 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,15 +1,50 @@ ## Grafana frontend packages -## Releasing new version +## Versioning We use [Lerna](https://github.com/lerna/lerna) for packages versioning and releases +All packages are versioned according to the current Grafana version: +- Grafana v6.3.0-alpha1 -> @grafana/* packages @ 6.3.0-alpha.1 +- Grafana v6.2.5 -> @grafana/* packages @ 6.2.5 +- Grafana - master branch version (based on package.json, i.e. 6.4.0-pre) -> @grafana/* packages @ 6.4.0-pre- (see details below about packages publishing channels) + +> Please note that @grafana/toolkit, @grafana/ui, @grafana/data & @grafana/runtime packages are considered ALPHA even though they are not released as alpha versions + +### Stable releases +> **Even though packages are released under a stable version, they are considered ALPHA until further notice!** + +Stable releases are published under `latest` tag on npm. + +### Alpha and beta releases +Alpha and beta releases are published under `next` tag on npm. + +### Automatic pre-releases +Every commit to master that has changes within `packages` directory is a subject of npm packages release. +*ALL* packages will be released under version from lerna.json file with commit SHA added to it: + +``` +- +``` + +Automatic prereleases are published under `canary` dist tag. + ### Manual release -1. Run `packages:prepare` script from root directory. This will perform cleanup, run all tests and bump version for all packages. Also, it will create `@packages@[version]` tag and version bump commit with `Packages: publish [version]` message. -2. Run `packages:build` script that will prepare distribution packages. -3. Run `packages:publish` to publish new versions - - add `--dist-tag next` to publish under `next` tag -4. Push version commit +> All of the steps below should be performed on a release branch, according to Grafana Release Guide + +> Make sure you are logged in to npm in your terminal and that you are a part of Grafana org on npm + +1. Run `yarn packages:prepare` script from root directory. This will perform tests on the packages and prompt for version of the packages. The version should be the same as the one being released. + - Make sure you use semver convention. So, *place a dot between prerelease id and prelease number*!!! i.e. 6.3.0-alpha.1 + - Make sure you confirm the version bump when prompted! +2. Commit changes (lerna.json & package.json files) - *"Packages version update: \"* +3. Run `yarn packages:build` script that will prepare distribution packages in `packages/grafana-*/dist`. These directories are going to be published to npm +4. Depending whether or not it's a prerelease: + - When releasing a prelease run `packages:publishNext` to publish new versions. + - When releasing a stable version run `packages:publishLatest` to publish new versions. + +5. Push version commit to the release branch ### Building individual packages To build induvidual packages run `grafana-toolkit package:build --scope=` + diff --git a/packages/grafana-data/README.md b/packages/grafana-data/README.md index 76401653d064..c63960de81fc 100644 --- a/packages/grafana-data/README.md +++ b/packages/grafana-data/README.md @@ -1,3 +1,5 @@ # Grafana Data Library -This package holds the root data types and functions used within Grafana. \ No newline at end of file +> **@grafana/data is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! + +This package holds the root data types and functions used within Grafana. diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 7acfffd95f12..634c74f6d957 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/data", - "version": "6.4.0-alpha.44", + "version": "6.4.0-pre", "description": "Grafana Data Library", "keywords": [ "typescript" diff --git a/packages/grafana-runtime/README.md b/packages/grafana-runtime/README.md index 852cc3327e97..87e6b374fd99 100644 --- a/packages/grafana-runtime/README.md +++ b/packages/grafana-runtime/README.md @@ -1,3 +1,5 @@ # Grafana Runtime library -This package allows access to grafana services. It requires Grafana to be running already and the functions to be imported as externals. \ No newline at end of file +> **@grafana/runtime is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! + +This package allows access to grafana services. It requires Grafana to be running already and the functions to be imported as externals. diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index b0052353bc92..b40a8ff9c9cf 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/runtime", - "version": "6.4.0-alpha.44", + "version": "6.4.0-pre", "description": "Grafana Runtime Library", "keywords": [ "grafana", diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index bb8f58636368..f940b6354331 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -1,14 +1,17 @@ +> **@grafana/toolkit is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! + # grafana-toolkit grafana-toolkit is CLI that enables efficient development of Grafana extensions + ## Rationale Historically, creating Grafana extension was an exercise of reverse engineering and ceremony around testing, developing and eventually building the plugin. We want to help our community to focus on the core value of their plugins rather than all the setup required to develop an extension. ## Installation You can either add grafana-toolkit to your extension's `package.json` file by running -`yarn add @grafana/toolkit` `npm instal @grafana/toolkit` or use one of our extension templates: +`yarn add @grafana/toolkit` or `npm instal @grafana/toolkit`, or use one of our extension templates: - [React Panel](https://github.com/grafana/simple-react-panel) - [Angular Panel](https://github.com/grafana/simple-angular-panel) @@ -92,7 +95,7 @@ Yes! grafana-toolkit supports Typescript by default. ### How can I test my extension? grafana-toolkit comes with Jest as a test runner. -Internally at Grafana we use Enzyme. If you are developing React extension and you want to configure Enzyme as a testing utility, you need to configure `enzyme-adapter-react`. To do so create `[YOUR_EXTENSION]/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React: +Internally at Grafana we use Enzyme. If you are developing React extension and you want to configure Enzyme as a testing utility, you need to configure `enzyme-adapter-react`. To do so create `/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React: ```ts import { configure } from 'enzyme'; @@ -101,7 +104,7 @@ import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); ``` -You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `[YOUR_EXTENSION]/config/jest-shim.ts` +You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `/config/jest-shim.ts` ### Can I provide custom setup for Jest? diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 919ef39f41f8..eab6081f3982 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/toolkit", - "version": "6.4.0-alpha.44", + "version": "6.4.0-pre", "description": "Grafana Toolkit", "keywords": [ "grafana", diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md index bc45a78e5765..06847c627015 100644 --- a/packages/grafana-ui/README.md +++ b/packages/grafana-ui/README.md @@ -1,5 +1,7 @@ # Grafana UI components library +> **@grafana/toolkit is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! + @grafana/ui is a collection of components used by [Grafana](https://github.com/grafana/grafana) Our goal is to deliver Grafana's common UI elements for plugins developers and contributors. diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 63de94851a0a..4d0ea1bbf329 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/ui", - "version": "6.4.0-alpha.44", + "version": "6.4.0-pre", "description": "Grafana Components Library", "keywords": [ "grafana", diff --git a/scripts/build/release-packages.sh b/scripts/build/release-packages.sh new file mode 100755 index 000000000000..061ebdd226e7 --- /dev/null +++ b/scripts/build/release-packages.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +GRAFANA_TAG=${1:-} +RELEASE_CHANNEL="latest" + +if echo "$GRAFANA_TAG" | grep -q "^v"; then + _grafana_version=$(echo "${GRAFANA_TAG}" | cut -d "v" -f 2) +else + echo "Provided tag is not a version tag, skipping packages release..." + exit +fi + +if grep -q "beta" <<< "$GRAFANA_TAG"; then + RELEASE_CHANNEL="next" +fi + +echo "$_grafana_version" + +# Get current version from lerna.json +# Since this happens on tagged branch, the lerna.json version and package.json file SHOULD be updated already +# as specified in release guideline +PACKAGE_VERSION=$(grep '"version"' lerna.json | cut -d '"' -f 4) + +echo "Releasing grafana packages @ ${PACKAGE_VERSION} under ${RELEASE_CHANNEL} channel" + +if [ $RELEASE_CHANNEL == "latest" ]; then + SCRIPT="publishLatest" +elif [ $RELEASE_CHANNEL == "next" ]; then + SCRIPT="publishNext" +else + echo "Unknown channel, skipping packages release" + exit +fi + +echo $'\nBuilding packages' +yarn packages:build + +echo $'\nPublishing packages' +yarn packages:${SCRIPT} + diff --git a/scripts/circle-release-next-packages.sh b/scripts/circle-release-next-packages.sh index b3c47ba68795..6b24fa95021b 100755 --- a/scripts/circle-release-next-packages.sh +++ b/scripts/circle-release-next-packages.sh @@ -16,9 +16,13 @@ function unpublish_previous_canary () { for PACKAGE in ui toolkit data runtime do # dist-tag next to be changed to canary when https://github.com/grafana/grafana/pull/18195 is merged - CURRENT_CANARY=$(npm view @grafana/${PACKAGE} dist-tags.next) - echo "Unpublish @grafana/${PACKAGE}@${CURRENT_CANARY}" - npm unpublish "@grafana/${PACKAGE}@${CURRENT_CANARY}" + CURRENT_CANARY=$(npm view @grafana/${PACKAGE} dist-tags.canary) + if [ -z "${CURRENT_CANARY}" ]; then + echo "@grafana/${PACKAGE} - Nothing to unpublish" + else + echo "Unpublish @grafana/${PACKAGE}@${CURRENT_CANARY}" + npm unpublish "@grafana/${PACKAGE}@${CURRENT_CANARY}" + fi done } @@ -57,6 +61,6 @@ else unpublish_previous_canary echo $'\nPublishing packages' - yarn packages:publishNext + yarn packages:publishCanary fi From 0994350e8b1f429633126ee2fbfb223d79b6431b Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 12 Sep 2019 08:47:18 +0200 Subject: [PATCH 17/87] TimeSeries: Add data frame index and field name (#19005) --- public/app/core/time_series2.ts | 14 ++++++++++++++ .../app/plugins/panel/graph/data_processor.ts | 17 ++++++++++++++--- .../__snapshots__/data_processor.test.ts.snap | 12 ++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index a0760c016c42..fdf31e993394 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -67,9 +67,21 @@ export function getDataMinMax(data: TimeSeries[]) { return { datamin, datamax }; } +/** + * @deprecated: This class should not be used in new panels + * + * Use DataFrame and helpers instead + */ export default class TimeSeries { datapoints: any; id: string; + // Represents index of original data frame in the quey response + dataFrameIndex: number; + /** + * Name of the field the time series was created from + * Used in graph panel to retrieve value from the original data frame + */ + fieldName: string; label: string; alias: string; aliasEscaped: string; @@ -110,6 +122,8 @@ export default class TimeSeries { this.stats = {}; this.legend = true; this.unit = opts.unit; + this.fieldName = opts.fieldName; + this.dataFrameIndex = opts.dataFrameIndex; this.hasMsResolution = this.isMsResolutionNeeded(); } diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index 6abaa282b436..07bc71e24f57 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -20,7 +20,8 @@ export class DataProcessor { return list; } - for (const series of dataList) { + for (let i = 0; i < dataList.length; i++) { + const series = dataList[i]; const { timeField } = getTimeField(series); if (!timeField) { continue; @@ -43,7 +44,7 @@ export class DataProcessor { datapoints.push([field.values.get(r), timeField.values.get(r)]); } - list.push(this.toTimeSeries(field, name, datapoints, list.length, range)); + list.push(this.toTimeSeries(field, name, i, datapoints, list.length, range)); } } @@ -56,10 +57,18 @@ export class DataProcessor { } return [first]; } + return list; } - private toTimeSeries(field: Field, alias: string, datapoints: any[][], index: number, range?: TimeRange) { + private toTimeSeries( + field: Field, + alias: string, + dataFrameIndex: number, + datapoints: any[][], + index: number, + range?: TimeRange + ) { const colorIndex = index % colors.length; const color = this.panel.aliasColors[alias] || colors[colorIndex]; @@ -68,6 +77,8 @@ export class DataProcessor { alias: alias, color: getColorFromHexRgbOrName(color, config.theme.type), unit: field.config ? field.config.unit : undefined, + fieldName: field.name, + dataFrameIndex, }); if (datapoints && datapoints.length > 0 && range) { diff --git a/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap b/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap index 99c3366fff3c..5cbc58d8074a 100644 --- a/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap +++ b/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap @@ -9,6 +9,7 @@ Array [ "fillColor": "#7EB26D", }, "color": "#7EB26D", + "dataFrameIndex": 0, "datapoints": Array [ Array [ 1, @@ -23,6 +24,7 @@ Array [ 1003, ], ], + "fieldName": "Value", "hasMsResolution": false, "id": "Value", "label": "Value", @@ -38,6 +40,7 @@ Array [ "fillColor": "#EAB839", }, "color": "#EAB839", + "dataFrameIndex": 1, "datapoints": Array [ Array [ 0.1, @@ -52,6 +55,7 @@ Array [ 1003, ], ], + "fieldName": "v1", "hasMsResolution": false, "id": "table_data v1", "label": "table_data v1", @@ -67,6 +71,7 @@ Array [ "fillColor": "#6ED0E0", }, "color": "#6ED0E0", + "dataFrameIndex": 1, "datapoints": Array [ Array [ 1.1, @@ -81,6 +86,7 @@ Array [ 1003, ], ], + "fieldName": "v2", "hasMsResolution": false, "id": "table_data v2", "label": "table_data v2", @@ -96,6 +102,7 @@ Array [ "fillColor": "#EF843C", }, "color": "#EF843C", + "dataFrameIndex": 2, "datapoints": Array [ Array [ 0.1, @@ -110,6 +117,7 @@ Array [ 1003, ], ], + "fieldName": "v1", "hasMsResolution": false, "id": "series v1", "label": "series v1", @@ -125,6 +133,7 @@ Array [ "fillColor": "#E24D42", }, "color": "#E24D42", + "dataFrameIndex": 2, "datapoints": Array [ Array [ 1.1, @@ -139,6 +148,7 @@ Array [ 1003, ], ], + "fieldName": "v2", "hasMsResolution": false, "id": "series v2", "label": "series v2", @@ -159,6 +169,7 @@ Array [ "fillColor": "#7EB26D", }, "color": "#7EB26D", + "dataFrameIndex": 0, "datapoints": Array [ Array [ 1, @@ -221,6 +232,7 @@ Array [ 1003, ], ], + "fieldName": "Value", "hasMsResolution": false, "id": "Value", "label": "Value", From c66a23ea3106901a9f77a0b8c54e9d824a726b7b Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 12 Sep 2019 09:36:57 +0200 Subject: [PATCH 18/87] DataLinksInput - change the way enter key is handled (#18985) --- .../grafana-ui/src/components/DataLinks/DataLinkInput.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 08d466c35aeb..c3463275cf6f 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -111,11 +111,13 @@ export const DataLinkInput: React.FC = ({ value, onChange, s setShowingSuggestions(true); } - if (event.key === 'Backspace') { + if (event.key === 'Enter') { + // Preventing entering a new line + // As of https://github.com/ianstormtaylor/slate/issues/1345#issuecomment-340508289 + return false; + } else { // @ts-ignore return; - } else { - return true; } }; From 0607189ed557fb99de92abb12dd17adfd93f7a4d Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 12 Sep 2019 09:55:15 +0200 Subject: [PATCH 19/87] TimeSeries: Replace fieldName with fieldIndex (#19030) --- public/app/core/time_series2.ts | 9 +++------ public/app/plugins/panel/graph/data_processor.ts | 9 +++++---- .../specs/__snapshots__/data_processor.test.ts.snap | 12 ++++++------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index fdf31e993394..6f88d13d7847 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -77,11 +77,8 @@ export default class TimeSeries { id: string; // Represents index of original data frame in the quey response dataFrameIndex: number; - /** - * Name of the field the time series was created from - * Used in graph panel to retrieve value from the original data frame - */ - fieldName: string; + // Represents index of field in the data frame + fieldIndex: number; label: string; alias: string; aliasEscaped: string; @@ -122,8 +119,8 @@ export default class TimeSeries { this.stats = {}; this.legend = true; this.unit = opts.unit; - this.fieldName = opts.fieldName; this.dataFrameIndex = opts.dataFrameIndex; + this.fieldIndex = opts.fieldIndex; this.hasMsResolution = this.isMsResolutionNeeded(); } diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index 07bc71e24f57..cd68c4993e26 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -28,7 +28,8 @@ export class DataProcessor { } const seriesName = series.name ? series.name : series.refId; - for (const field of series.fields) { + for (let j = 0; j < series.fields.length; j++) { + const field = series.fields[j]; if (field.type !== FieldType.number) { continue; } @@ -43,8 +44,7 @@ export class DataProcessor { for (let r = 0; r < series.length; r++) { datapoints.push([field.values.get(r), timeField.values.get(r)]); } - - list.push(this.toTimeSeries(field, name, i, datapoints, list.length, range)); + list.push(this.toTimeSeries(field, name, i, j, datapoints, list.length, range)); } } @@ -65,6 +65,7 @@ export class DataProcessor { field: Field, alias: string, dataFrameIndex: number, + fieldIndex: number, datapoints: any[][], index: number, range?: TimeRange @@ -77,8 +78,8 @@ export class DataProcessor { alias: alias, color: getColorFromHexRgbOrName(color, config.theme.type), unit: field.config ? field.config.unit : undefined, - fieldName: field.name, dataFrameIndex, + fieldIndex, }); if (datapoints && datapoints.length > 0 && range) { diff --git a/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap b/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap index 5cbc58d8074a..0c2265b9a8f4 100644 --- a/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap +++ b/public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap @@ -24,7 +24,7 @@ Array [ 1003, ], ], - "fieldName": "Value", + "fieldIndex": 0, "hasMsResolution": false, "id": "Value", "label": "Value", @@ -55,7 +55,7 @@ Array [ 1003, ], ], - "fieldName": "v1", + "fieldIndex": 1, "hasMsResolution": false, "id": "table_data v1", "label": "table_data v1", @@ -86,7 +86,7 @@ Array [ 1003, ], ], - "fieldName": "v2", + "fieldIndex": 2, "hasMsResolution": false, "id": "table_data v2", "label": "table_data v2", @@ -117,7 +117,7 @@ Array [ 1003, ], ], - "fieldName": "v1", + "fieldIndex": 0, "hasMsResolution": false, "id": "series v1", "label": "series v1", @@ -148,7 +148,7 @@ Array [ 1003, ], ], - "fieldName": "v2", + "fieldIndex": 1, "hasMsResolution": false, "id": "series v2", "label": "series v2", @@ -232,7 +232,7 @@ Array [ 1003, ], ], - "fieldName": "Value", + "fieldIndex": 0, "hasMsResolution": false, "id": "Value", "label": "Value", From bc4ba64a249a3d1143e910a60dc656ab3740a49d Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Thu, 12 Sep 2019 10:02:49 +0200 Subject: [PATCH 20/87] Explore: Fix auto completion on label values for Loki (#18988) --- .../loki/components/useLokiLabels.test.ts | 13 +- .../loki/components/useLokiSyntax.test.ts | 5 +- .../datasource/loki/language_provider.test.ts | 150 ++++++++++-------- .../datasource/loki/language_provider.ts | 31 +++- public/app/plugins/datasource/loki/mocks.ts | 27 ++++ 5 files changed, 139 insertions(+), 87 deletions(-) create mode 100644 public/app/plugins/datasource/loki/mocks.ts diff --git a/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts b/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts index 01a71fa30af9..4be04f2a81a6 100644 --- a/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts +++ b/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts @@ -3,12 +3,11 @@ import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; import { useLokiLabels } from './useLokiLabels'; import { DataSourceStatus } from '@grafana/ui/src/types/datasource'; import { AbsoluteTimeRange } from '@grafana/data'; +import { makeMockLokiDatasource } from '../mocks'; describe('useLokiLabels hook', () => { it('should refresh labels', async () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as any[] } }), - }; + const datasource = makeMockLokiDatasource({}); const languageProvider = new LanguageProvider(datasource); const logLabelOptionsMock = ['Holy mock!']; const rangeMock: AbsoluteTimeRange = { @@ -31,9 +30,7 @@ describe('useLokiLabels hook', () => { }); it('should force refresh labels after a disconnect', () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as any[] } }), - }; + const datasource = makeMockLokiDatasource({}); const rangeMock: AbsoluteTimeRange = { from: 1560153109000, @@ -52,9 +49,7 @@ describe('useLokiLabels hook', () => { }); it('should not force refresh labels after a connect', () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as any[] } }), - }; + const datasource = makeMockLokiDatasource({}); const rangeMock: AbsoluteTimeRange = { from: 1560153109000, diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts index 99e7904c347e..62de5c156adc 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts +++ b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts @@ -5,11 +5,10 @@ import { AbsoluteTimeRange } from '@grafana/data'; import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; import { useLokiSyntax } from './useLokiSyntax'; import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; +import { makeMockLokiDatasource } from '../mocks'; describe('useLokiSyntax hook', () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as any[] } }), - }; + const datasource = makeMockLokiDatasource({}); const languageProvider = new LanguageProvider(datasource); const logLabelOptionsMock = ['Holy mock!']; const logLabelOptionsMock2 = ['Mock the hell?!']; diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts index fd4a84d360e2..4f0ac9324aa2 100644 --- a/public/app/plugins/datasource/loki/language_provider.test.ts +++ b/public/app/plugins/datasource/loki/language_provider.test.ts @@ -1,16 +1,16 @@ // @ts-ignore import Plain from 'slate-plain-serializer'; -import LanguageProvider, { LABEL_REFRESH_INTERVAL, rangeToParams } from './language_provider'; +import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider'; import { AbsoluteTimeRange } from '@grafana/data'; import { advanceTo, clear, advanceBy } from 'jest-date-mock'; import { beforeEach } from 'test/lib/common'; -import { DataQueryResponseData } from '@grafana/ui'; +import { DataSourceApi } from '@grafana/ui'; +import { TypeaheadInput } from '../../../types'; +import { makeMockLokiDatasource } from './mocks'; describe('Language completion provider', () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }), - }; + const datasource = makeMockLokiDatasource({}); const rangeMock: AbsoluteTimeRange = { from: 1560153109000, @@ -30,9 +30,10 @@ describe('Language completion provider', () => { it('returns default suggestions with history on empty context when history was provided', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const history = [ + const history: LokiHistoryItem[] = [ { query: { refId: '1', expr: '{app="foo"}' }, + ts: 1, }, ]; const result = instance.provideCompletionItems( @@ -55,25 +56,14 @@ describe('Language completion provider', () => { it('returns no suggestions within regexp', () => { const instance = new LanguageProvider(datasource); - const value = Plain.deserialize('{} ()'); - const range = value.selection.merge({ - anchorOffset: 4, - }); - const valueWithSelection = value.change().select(range).value; - const history = [ + const input = createTypeaheadInput('{} ()', '', undefined, 4, []); + const history: LokiHistoryItem[] = [ { query: { refId: '1', expr: '{app="foo"}' }, + ts: 1, }, ]; - const result = instance.provideCompletionItems( - { - text: '', - prefix: '', - value: valueWithSelection, - wrapperClasses: [], - }, - { history } - ); + const result = instance.provideCompletionItems(input, { history }); expect(result.context).toBeUndefined(); expect(result.refresher).toBeUndefined(); expect(result.suggestions.length).toEqual(0); @@ -83,23 +73,35 @@ describe('Language completion provider', () => { describe('label suggestions', () => { it('returns default label suggestions on label context', () => { const instance = new LanguageProvider(datasource); - const value = Plain.deserialize('{}'); - const range = value.selection.merge({ - anchorOffset: 1, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems( - { - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }, - { absoluteRange: rangeMock } - ); + const input = createTypeaheadInput('{}', ''); + const result = instance.provideCompletionItems(input, { absoluteRange: rangeMock }); expect(result.context).toBe('context-labels'); expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]); }); + + it('returns label suggestions from Loki', async () => { + const datasource = makeMockLokiDatasource({ label1: [], label2: [] }); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{}', ''); + const result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }, { label: 'label2' }], label: 'Labels' }]); + }); + + it('returns label values suggestions from Loki', async () => { + const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] }); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{label1=}', '=', 'label1'); + let result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + // The values for label are loaded adhoc and there is a promise returned that we have to wait for + expect(result.refresher).toBeDefined(); + await result.refresher; + result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([ + { items: [{ label: 'label1_val1' }, { label: 'label1_val2' }], label: 'Label values for "label1"' }, + ]); + }); }); }); @@ -110,17 +112,8 @@ describe('Request URL', () => { to: 1560163909000, }; - const datasourceWithLabels = { - metadataRequest: (url: string) => { - if (url.slice(0, 15) === '/api/prom/label') { - return { data: { data: ['other'] } }; - } else { - return { data: { data: [] } }; - } - }, - }; - - const datasourceSpy = jest.spyOn(datasourceWithLabels, 'metadataRequest'); + const datasourceWithLabels = makeMockLokiDatasource({ other: [] }); + const datasourceSpy = jest.spyOn(datasourceWithLabels as any, 'metadataRequest'); const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock }); await instance.refreshLogLabels(rangeMock, true); @@ -130,9 +123,7 @@ describe('Request URL', () => { }); describe('Query imports', () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }), - }; + const datasource = makeMockLokiDatasource({}); const rangeMock: AbsoluteTimeRange = { from: 1560153109000, @@ -153,36 +144,21 @@ describe('Query imports', () => { }); it('returns empty query from selector query if label is not available', async () => { - const datasourceWithLabels = { - metadataRequest: (url: string) => - url.slice(0, 15) === '/api/prom/label' - ? { data: { data: ['other'] } } - : { data: { data: [] as DataQueryResponseData[] } }, - }; + const datasourceWithLabels = makeMockLokiDatasource({ other: [] }); const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock }); const result = await instance.importPrometheusQuery('{foo="bar"}'); expect(result).toEqual('{}'); }); it('returns selector query from selector query with common labels', async () => { - const datasourceWithLabels = { - metadataRequest: (url: string) => - url.slice(0, 15) === '/api/prom/label' - ? { data: { data: ['foo'] } } - : { data: { data: [] as DataQueryResponseData[] } }, - }; + const datasourceWithLabels = makeMockLokiDatasource({ foo: [] }); const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock }); const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}'); expect(result).toEqual('{foo="bar"}'); }); it('returns selector query from selector query with all labels if logging label list is empty', async () => { - const datasourceWithLabels = { - metadataRequest: (url: string) => - url.slice(0, 15) === '/api/prom/label' - ? { data: { data: [] as DataQueryResponseData[] } } - : { data: { data: [] as DataQueryResponseData[] } }, - }; + const datasourceWithLabels = makeMockLokiDatasource({}); const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock }); const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}'); expect(result).toEqual('{baz="42",foo="bar"}'); @@ -191,9 +167,7 @@ describe('Query imports', () => { }); describe('Labels refresh', () => { - const datasource = { - metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }), - }; + const datasource = makeMockLokiDatasource({}); const instance = new LanguageProvider(datasource); const rangeMock: AbsoluteTimeRange = { @@ -226,3 +200,39 @@ describe('Labels refresh', () => { expect(instance.fetchLogLabels).toBeCalled(); }); }); + +async function getLanguageProvider(datasource: DataSourceApi) { + const instance = new LanguageProvider(datasource); + instance.initialRange = { + from: Date.now() - 10000, + to: Date.now(), + }; + await instance.start(); + return instance; +} + +/** + * @param value Value of the full input + * @param text Last piece of text (not sure but in case of {label=} this would be just '=') + * @param labelKey Label by which to search for values. Cutting corners a bit here as this should be inferred from value + */ +function createTypeaheadInput( + value: string, + text: string, + labelKey?: string, + anchorOffset?: number, + wrapperClasses?: string[] +): TypeaheadInput { + const deserialized = Plain.deserialize(value); + const range = deserialized.selection.merge({ + anchorOffset: anchorOffset || 1, + }); + const valueWithSelection = deserialized.change().select(range).value; + return { + text, + prefix: '', + wrapperClasses: wrapperClasses || ['context-labels'], + value: valueWithSelection, + labelKey, + }; +} diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 9dac44efc0a3..36c5eb11860c 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -17,6 +17,7 @@ import { import { LokiQuery } from './types'; import { dateTime, AbsoluteTimeRange } from '@grafana/data'; import { PromQuery } from '../prometheus/types'; +import { DataSourceApi } from '@grafana/ui'; const DEFAULT_KEYS = ['job', 'namespace']; const EMPTY_SELECTOR = '{}'; @@ -28,7 +29,12 @@ export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec const wrapLabel = (label: string) => ({ label }); export const rangeToParams = (range: AbsoluteTimeRange) => ({ start: range.from * NS_IN_MS, end: range.to * NS_IN_MS }); -type LokiHistoryItem = HistoryItem; +export type LokiHistoryItem = HistoryItem; + +type TypeaheadContext = { + history?: LokiHistoryItem[]; + absoluteRange?: AbsoluteTimeRange; +}; export function addHistoryMetadata(item: CompletionItem, history: LokiHistoryItem[]): CompletionItem { const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; @@ -54,7 +60,7 @@ export default class LokiLanguageProvider extends LanguageProvider { started: boolean; initialRange: AbsoluteTimeRange; - constructor(datasource: any, initialValues?: any) { + constructor(datasource: DataSourceApi, initialValues?: any) { super(); this.datasource = datasource; @@ -74,6 +80,10 @@ export default class LokiLanguageProvider extends LanguageProvider { return this.datasource.metadataRequest(url, params); }; + /** + * Initialise the language provider by fetching set of labels. Without this initialisation the provider would return + * just a set of hardcoded default labels on provideCompletionItems or a recent queries from history. + */ start = () => { if (!this.startTask) { this.startTask = this.fetchLogLabels(this.initialRange); @@ -81,14 +91,22 @@ export default class LokiLanguageProvider extends LanguageProvider { return this.startTask; }; - // Keep this DOM-free for testing - provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput { + /** + * Return suggestions based on input that can be then plugged into a typeahead dropdown. + * Keep this DOM-free for testing + * @param input + * @param context Is optional in types but is required in case we are doing getLabelCompletionItems + * @param context.absoluteRange Required in case we are doing getLabelCompletionItems + * @param context.history Optional used only in getEmptyCompletionItems + */ + provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): TypeaheadOutput { + const { wrapperClasses, value } = input; // Local text properties const empty = value.document.text.length === 0; // Determine candidates by CSS context if (_.includes(wrapperClasses, 'context-labels')) { // Suggestions for {|} and {foo=|} - return this.getLabelCompletionItems.apply(this, arguments); + return this.getLabelCompletionItems(input, context); } else if (empty) { return this.getEmptyCompletionItems(context || {}); } @@ -245,6 +263,9 @@ export default class LokiLanguageProvider extends LanguageProvider { ...this.labelKeys, [EMPTY_SELECTOR]: labelKeys, }; + this.labelValues = { + [EMPTY_SELECTOR]: {}, + }; this.logLabelOptions = labelKeys.map((key: string) => ({ label: key, value: key, isLeaf: false })); } catch (e) { console.error(e); diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts new file mode 100644 index 000000000000..49c2de7dcc01 --- /dev/null +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -0,0 +1,27 @@ +import { DataSourceApi } from '@grafana/ui'; + +export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): DataSourceApi { + const labels = Object.keys(labelsAndValues); + return { + metadataRequest: (url: string) => { + let responseData; + if (url === '/api/prom/label') { + responseData = labels; + } else { + const match = url.match(/^\/api\/prom\/label\/(\w*)\/values/); + if (match) { + responseData = labelsAndValues[match[1]]; + } + } + if (responseData) { + return { + data: { + data: responseData, + }, + }; + } else { + throw new Error(`Unexpected url error, ${url}`); + } + }, + } as any; +} From e4e7719428c4ee188d658fa0752399374077dc86 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 12 Sep 2019 01:17:41 -0700 Subject: [PATCH 21/87] toolkit: run make for backend plugins (#19029) --- packages/grafana-toolkit/src/cli/index.ts | 8 ++++++-- .../grafana-toolkit/src/cli/tasks/plugin.ci.ts | 18 +++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index 99b17e428b67..ad2fa7c1c1b5 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -156,9 +156,13 @@ export const run = (includeInternalScripts = false) => { program .command('plugin:ci-build') - .option('--backend ', 'For backend task, which backend to run') - .description('Build the plugin, leaving artifacts in /dist') + .option('--backend', 'Run Makefile for backend task', false) + .description('Build the plugin, leaving results in /dist and /coverage') .action(async cmd => { + if (typeof cmd === 'string') { + console.error(`Invalid argument: ${cmd}\nSee --help for a list of available commands.`); + process.exit(1); + } await execTask(ciBuildPluginTask)({ backend: cmd.backend, }); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index efef4cd549b5..f8388df63816 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -35,7 +35,7 @@ import { runEndToEndTests } from '../../plugins/e2e/launcher'; import { getEndToEndSettings } from '../../plugins/index'; export interface PluginCIOptions { - backend?: string; + backend?: boolean; full?: boolean; upload?: boolean; } @@ -58,14 +58,14 @@ const buildPluginRunner: TaskRunner = async ({ backend }) => { fs.mkdirSync(workDir); if (backend) { - console.log('TODO, backend support?'); - fs.mkdirSync(path.resolve(process.cwd(), 'dist')); - const file = path.resolve(process.cwd(), 'dist', `README_${backend}.txt`); - fs.writeFile(file, `TODO... build bakend plugin: ${backend}!`, err => { - if (err) { - throw new Error('Unable to write: ' + file); - } - }); + const makefile = path.resolve(process.cwd(), 'Makefile'); + if (!fs.existsSync(makefile)) { + throw new Error(`Missing: ${makefile}. A Makefile is required for backend plugins.`); + } + + // Run plugin-ci task + const exe = await execa('make', ['backend-plugin-ci']); + console.log(exe.stdout); } else { // Do regular build process with coverage await pluginBuildRunner({ coverage: true }); From dc0bfb26cfd27ab441bae7c5e9043b10a53e913b Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Thu, 12 Sep 2019 14:42:28 +0200 Subject: [PATCH 22/87] Fix: Align buttons and label in ToggleButtonGroup (#19036) --- .../ToggleButtonGroup.story.tsx | 49 +++++++++++++++++++ .../ToggleButtonGroup/ToggleButtonGroup.tsx | 4 +- public/sass/components/_gf-form.scss | 4 ++ .../sass/components/_toggle_button_group.scss | 2 +- public/sass/pages/_explore.scss | 4 -- 5 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.story.tsx diff --git a/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.story.tsx b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.story.tsx new file mode 100644 index 000000000000..d187a6f29aff --- /dev/null +++ b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.story.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { ToggleButton, ToggleButtonGroup } from './ToggleButtonGroup'; +import { UseState } from '../../utils/storybook/UseState'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; + +const ToggleButtonGroupStories = storiesOf('UI/ToggleButtonGroup', module); + +const options = [ + { value: 'first', label: 'First' }, + { value: 'second', label: 'Second' }, + { value: 'third', label: 'Third' }, +]; + +ToggleButtonGroupStories.addDecorator(withCenteredStory); + +ToggleButtonGroupStories.add('default', () => { + return ( + + {(value, updateValue) => { + return ( + + {options.map((option, index) => { + return ( + { + action('on change')(newValue); + updateValue({ value: newValue }); + }} + selected={value.value === option.value} + > + {option.label} + + ); + })} + + ); + }} + + ); +}); diff --git a/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx index e9600d9971e1..51798b7b8748 100644 --- a/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -12,7 +12,7 @@ export class ToggleButtonGroup extends PureComponent { const { children, label, transparent } = this.props; return ( -
+
{label && }
{children}
@@ -44,7 +44,7 @@ export const ToggleButton: FC = ({ } }; - const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; + const btnClassName = `btn ${className}${selected ? ' active' : ''}`; const button = ( diff --git a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx index 070332e249b0..57b0b6987f06 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx @@ -165,6 +165,7 @@ export class LokiQueryFieldForm extends React.PureComponent { if (isVisible && onLabelsRefresh) { onLabelsRefresh(); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index 8f317799ffbc..99eef38bde3a 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -308,7 +308,7 @@ class PromQueryField extends React.PureComponent
- + diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 636871e78977..0018eaa7db35 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -320,8 +320,7 @@ z-index: inherit; } -// React-component cascade fix: show "loading" even though item can expand - +// React-component cascade fix: show "loading" when loading children .rc-cascader-menu-item-loading:after { position: absolute; right: 12px; @@ -330,6 +329,11 @@ font-style: italic; } +// React-component cascade fix: vertical alignment issue with Safari +.rc-cascader-menu { + vertical-align: top; +} + // TODO Experimental .cheat-sheet-item { diff --git a/yarn.lock b/yarn.lock index 1a4fa4be6538..435e9de871f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4081,9 +4081,10 @@ array-reduce@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" -array-tree-filter@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/array-tree-filter/-/array-tree-filter-1.0.1.tgz#0a8ad1eefd38ce88858632f9cc0423d7634e4d5d" +array-tree-filter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-tree-filter/-/array-tree-filter-2.1.0.tgz#873ac00fec83749f255ac8dd083814b4f6329190" + integrity sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw== array-union@^1.0.1: version "1.0.2" @@ -14711,14 +14712,16 @@ rc-animate@2.x: raf "^3.4.0" react-lifecycles-compat "^3.0.4" -rc-cascader@0.14.0: - version "0.14.0" - resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-0.14.0.tgz#a956c99896f10883bf63d46fb894d0cb326842a4" +rc-cascader@0.17.5: + version "0.17.5" + resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-0.17.5.tgz#4fde91d23b7608c420263c38eee9c0687f80f7dc" + integrity sha512-WYMVcxU0+Lj+xLr4YYH0+yXODumvNXDcVEs5i7L1mtpWwYkubPV/zbQpn+jGKFCIW/hOhjkU4J1db8/P/UKE7A== dependencies: - array-tree-filter "^1.0.0" + array-tree-filter "^2.1.0" prop-types "^15.5.8" rc-trigger "^2.2.0" rc-util "^4.0.4" + react-lifecycles-compat "^3.0.4" shallow-equal "^1.0.0" warning "^4.0.1" From 3742db720f7b5aeab063de73296265159fb887a4 Mon Sep 17 00:00:00 2001 From: Jess <53754871+jessover9000@users.noreply.github.com> Date: Thu, 12 Sep 2019 16:01:22 +0200 Subject: [PATCH 24/87] Fix exit live mode icon: change back to Stop. (#19043) --- public/app/features/explore/LiveLogs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/explore/LiveLogs.tsx b/public/app/features/explore/LiveLogs.tsx index c80e813fabbe..49312f6e8ffc 100644 --- a/public/app/features/explore/LiveLogs.tsx +++ b/public/app/features/explore/LiveLogs.tsx @@ -201,7 +201,7 @@ class LiveLogs extends PureComponent { {isPaused ? 'Resume' : 'Pause'} {isPaused || ( From 140ecbcf792a14c5bb537fec74c9a565cd646889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 12 Sep 2019 17:28:46 +0200 Subject: [PATCH 25/87] QueryProcessing: Observable query interface and RxJS for query & stream processing (#18899) * I needed to learn some rxjs and understand this more, so just playing around * Updated * Removed all the complete calls * Refactoring * StreamHandler -> observable start * progress * simple singal works * Handle update time range * added error handling * wrap old function * minor changes * handle data format in the subscribe function * Use replay subject to return last value to subscribers * Set loading state after no response in 50ms * added missing file * updated comment * Added cancelation of network requests * runRequest: Added unit test scenario framework * Progress on tests * minor refactor of unit tests * updated test * removed some old code * Shared queries work again, and also became so much simplier * unified query and observe methods * implict any fix * Fixed closed subject issue * removed comment * Use last returned data for loading state * WIP: Explore to runRequest makover step1 * Minor progress * Minor progress on explore and runRequest * minor progress * Things are starting to work in explore * Updated prometheus to use new observable query response, greatly simplified code * Revert refId change * Found better solution for key/refId/requestId problem * use observable with loki * tests compile * fix loki query prep * Explore: correct first response handling * Refactorings * Refactoring * Explore: Fixes LoadingState and GraphResults between runs (#18986) * Refactor: Adds state to DataQueryResponse * Fix: Fixes so we do not empty results before new data arrives Fixes: #17409 * Transformations work * observable test data * remove single() from loki promise * Fixed comment * Explore: Fixes failing Loki and Prometheus unit tests (#18995) * Tests: Makes datasource tests work again * Fix: Fixes loki datasource so highligthing works * Chore: Runs Prettier * Fixed query runner tests * Delay loading state indication to 200ms * Fixed test * fixed unit tests * Clear cached calcs * Fixed bug getProcesedDataFrames * Fix the correct test is a better idea * Fix: Fixes so queries in Explore are only run if Graph/Table is shown (#19000) * Fix: Fixes so queries in Explore are only run if Graph/Table is shown Fixes: #18618 * Refactor: Removes unnecessary condition * PanelData: provide legacy data only when needed (#19018) * no legacy * invert logic... now compiles * merge getQueryResponseData and getDataRaw * update comment about query editor * use single getData() function * only send legacy when it is used in explore * pre process rather than post process * pre process rather than post process * Minor refactoring * Add missing tags to test datasource response * MixedDatasource: Adds query observable pattern to MixedDatasource (#19037) * start mixed datasource * Refactor: Refactors into observable parttern * Tests: Fixes tests * Tests: Removes console.log * Refactor: Adds unique requestId --- .../grafana-data/src/utils/dataFrameHelper.ts | 2 +- .../grafana-data/src/utils/fieldReducer.ts | 3 + .../src/utils/processDataFrame.ts | 5 +- .../TransformersUI/TransformationsEditor.tsx | 6 +- packages/grafana-ui/src/types/datasource.ts | 35 +- packages/grafana-ui/src/types/panel.ts | 5 +- public/app/core/services/backend_srv.ts | 1 + public/app/core/utils/explore.test.ts | 25 -- public/app/core/utils/explore.ts | 16 +- .../dashboard/dashgrid/PanelChrome.tsx | 12 +- .../dashboard/panel_editor/QueriesTab.tsx | 34 +- .../dashboard/panel_editor/QueryEditorRow.tsx | 27 +- .../features/dashboard/state/PanelModel.ts | 12 +- .../dashboard/state/PanelQueryRunner.test.ts | 124 +----- .../dashboard/state/PanelQueryRunner.ts | 210 +++------- .../dashboard/state/PanelQueryState.test.ts | 238 ----------- .../dashboard/state/PanelQueryState.ts | 377 ------------------ .../dashboard/state/runRequest.test.ts | 206 ++++++++++ .../features/dashboard/state/runRequest.ts | 211 ++++++++++ .../app/features/explore/state/actionTypes.ts | 10 + public/app/features/explore/state/actions.ts | 51 +-- .../features/explore/state/reducers.test.ts | 31 +- public/app/features/explore/state/reducers.ts | 74 ++-- .../explore/utils/ResultProcessor.test.ts | 8 +- .../features/explore/utils/ResultProcessor.ts | 12 +- .../app/features/panel/metrics_panel_ctrl.ts | 27 +- .../dashboard/DashboardQueryEditor.tsx | 2 +- .../datasource/dashboard/SharedQueryRunner.ts | 116 ------ .../app/plugins/datasource/dashboard/index.ts | 3 + ...unner.test.ts => runSharedRequest.test.ts} | 2 +- .../datasource/dashboard/runSharedRequest.ts | 80 ++++ .../app/plugins/datasource/dashboard/types.ts | 2 + .../datasource/loki/datasource.test.ts | 2 +- .../app/plugins/datasource/loki/datasource.ts | 107 ++--- .../datasource/mixed/MixedDataSource.test.ts | 16 +- .../datasource/mixed/MixedDataSource.ts | 84 ++-- .../plugins/datasource/postgres/query_ctrl.ts | 1 + .../datasource/prometheus/datasource.ts | 162 +++----- .../prometheus/specs/datasource.test.ts | 273 +++++++++++-- .../plugins/datasource/prometheus/types.ts | 3 + .../plugins/datasource/testdata/LogIpsum.ts | 162 ++++++++ .../plugins/datasource/testdata/datasource.ts | 126 +++--- .../plugins/datasource/testdata/query_ctrl.ts | 2 +- .../plugins/datasource/testdata/runStreams.ts | 224 +++++++++++ public/app/plugins/panel/graph/module.ts | 5 +- .../panel/graph/specs/data_processor.test.ts | 2 +- public/app/plugins/panel/singlestat/module.ts | 5 +- public/app/types/explore.ts | 4 +- public/test/core/redux/reducerTester.ts | 6 +- 49 files changed, 1623 insertions(+), 1528 deletions(-) delete mode 100644 public/app/features/dashboard/state/PanelQueryState.test.ts delete mode 100644 public/app/features/dashboard/state/PanelQueryState.ts create mode 100644 public/app/features/dashboard/state/runRequest.test.ts create mode 100644 public/app/features/dashboard/state/runRequest.ts delete mode 100644 public/app/plugins/datasource/dashboard/SharedQueryRunner.ts create mode 100644 public/app/plugins/datasource/dashboard/index.ts rename public/app/plugins/datasource/dashboard/{SharedQueryRunner.test.ts => runSharedRequest.test.ts} (90%) create mode 100644 public/app/plugins/datasource/dashboard/runSharedRequest.ts create mode 100644 public/app/plugins/datasource/testdata/LogIpsum.ts create mode 100644 public/app/plugins/datasource/testdata/runStreams.ts diff --git a/packages/grafana-data/src/utils/dataFrameHelper.ts b/packages/grafana-data/src/utils/dataFrameHelper.ts index d890608d5c47..15b03bf72bdb 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.ts +++ b/packages/grafana-data/src/utils/dataFrameHelper.ts @@ -393,8 +393,8 @@ export class CircularDataFrame extends MutableDataFrame { constructor(options: CircularOptions) { super(undefined, (buffer?: any[]) => { return new CircularVector({ - buffer, ...options, + buffer, }); }); } diff --git a/packages/grafana-data/src/utils/fieldReducer.ts b/packages/grafana-data/src/utils/fieldReducer.ts index 428968f4043e..7655a735e3df 100644 --- a/packages/grafana-data/src/utils/fieldReducer.ts +++ b/packages/grafana-data/src/utils/fieldReducer.ts @@ -101,6 +101,7 @@ export function reduceField(options: ReduceFieldOptions): FieldCalcs { // For now everything can use the standard stats let values = doStandardCalcs(field, ignoreNulls, nullAsZero); + for (const reducer of queue) { if (!values.hasOwnProperty(reducer.id) && reducer.reduce) { values = { @@ -109,10 +110,12 @@ export function reduceField(options: ReduceFieldOptions): FieldCalcs { }; } } + field.calcs = { ...field.calcs, ...values, }; + return values; } diff --git a/packages/grafana-data/src/utils/processDataFrame.ts b/packages/grafana-data/src/utils/processDataFrame.ts index c6d35126ac97..542fb950721e 100644 --- a/packages/grafana-data/src/utils/processDataFrame.ts +++ b/packages/grafana-data/src/utils/processDataFrame.ts @@ -238,16 +238,19 @@ export const toDataFrame = (data: any): DataFrame => { // This will convert the array values into Vectors return new MutableDataFrame(data as DataFrameDTO); } + if (data.hasOwnProperty('datapoints')) { return convertTimeSeriesToDataFrame(data); } + if (data.hasOwnProperty('data')) { return convertGraphSeriesToDataFrame(data); } + if (data.hasOwnProperty('columns')) { return convertTableToDataFrame(data); } - // TODO, try to convert JSON/Array to table? + console.warn('Can not convert', data); throw new Error('Unsupported data format'); }; diff --git a/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx index 151d7df2c40a..d026c3918900 100644 --- a/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx +++ b/packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx @@ -13,7 +13,7 @@ interface TransformationsEditorState { interface TransformationsEditorProps { onChange: (transformations: DataTransformerConfig[]) => void; transformations: DataTransformerConfig[]; - getCurrentData: (applyTransformations?: boolean) => DataFrame[]; + dataFrames: DataFrame[]; } export class TransformationsEditor extends React.PureComponent { @@ -46,9 +46,9 @@ export class TransformationsEditor extends React.PureComponent { - const { transformations, getCurrentData } = this.props; + const { transformations, dataFrames } = this.props; const hasTransformations = transformations.length > 0; - const preTransformData = getCurrentData(false); + const preTransformData = dataFrames; if (!hasTransformations) { return undefined; diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index 5482ac3734cd..44b60e4f335f 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -13,6 +13,7 @@ import { } from '@grafana/data'; import { PluginMeta, GrafanaPlugin } from './plugin'; import { PanelData } from './panel'; +import { Observable } from 'rxjs'; // NOTE: this seems more general than just DataSource export interface DataSourcePluginOptionsEditorProps { @@ -189,26 +190,9 @@ export abstract class DataSourceApi< init?: () => void; /** - * Query for data, and optionally stream results to an observer. - * - * Are you reading these docs aiming to execute a query? - * +-> If Yes, then consider using panelQueryRunner/State instead. see: - * * {@link https://github.com/grafana/grafana/blob/master/public/app/features/dashboard/state/PanelQueryRunner.ts PanelQueryRunner.ts} - * * {@link https://github.com/grafana/grafana/blob/master/public/app/features/dashboard/state/PanelQueryState.ts PanelQueryState.ts} - * - * If you are implementing a simple request-response query, - * then you can ignore the `observer` entirely. - * - * When streaming behavior is required, the Promise can return at any time - * with empty or partial data in the response and optionally a state. - * NOTE: The data in this initial response will not be replaced with any - * data from subsequent events. {@see DataStreamState} - * - * The request object will be passed in each observer callback - * so the callback could assert that the correct events are streaming and - * unsubscribe if unexpected results are returned. + * Query for data, and optionally stream results */ - abstract query(request: DataQueryRequest, observer?: DataStreamObserver): Promise; + abstract query(request: DataQueryRequest): Promise | Observable; /** * Test & verify datasource settings & connection details @@ -397,6 +381,19 @@ export interface DataQueryResponse { * or a partial result set */ data: DataQueryResponseData[]; + + /** + * When returning multiple partial responses or streams + * Use this key to inform Grafana how to combine the partial responses + * Multiple responses with same key are replaced (latest used) + */ + key?: string; + + /** + * Use this to control which state the response should have + * Defaults to LoadingState.Done if state is not defined + */ + state?: LoadingState; } export interface DataQuery { diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index cb0e69d15ea5..a104122c6fb7 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,6 +1,6 @@ import { ComponentClass, ComponentType } from 'react'; import { LoadingState, DataFrame, TimeRange, TimeZone, ScopedVars } from '@grafana/data'; -import { DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource'; +import { DataQueryRequest, DataQueryError } from './datasource'; import { PluginMeta, GrafanaPlugin } from './plugin'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -16,9 +16,6 @@ export interface PanelData { series: DataFrame[]; request?: DataQueryRequest; error?: DataQueryError; - - // Data format expected by Angular panels - legacy?: LegacyResponseData[]; } export interface PanelProps { diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 88d4fedbf5c2..bb8cc44a7a90 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -157,6 +157,7 @@ export class BackendSrv implements BackendService { // is canceled, canceling the previous datasource request if it is still // in-flight. const requestId = options.requestId; + if (requestId) { this.resolveCancelerIfExists(requestId); // create new canceler diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index a5408abe2860..328bf1b5f800 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -5,7 +5,6 @@ import { updateHistory, clearHistory, hasNonEmptyQuery, - instanceOfDataQueryError, getValueWithRefId, getFirstQueryErrorWithoutRefId, getRefIds, @@ -201,30 +200,6 @@ describe('hasNonEmptyQuery', () => { }); }); -describe('instanceOfDataQueryError', () => { - describe('when called with a DataQueryError', () => { - it('then it should return true', () => { - const error: DataQueryError = { - message: 'A message', - status: '200', - statusText: 'Ok', - }; - const result = instanceOfDataQueryError(error); - - expect(result).toBe(true); - }); - }); - - describe('when called with a non DataQueryError', () => { - it('then it should return false', () => { - const error = {}; - const result = instanceOfDataQueryError(error); - - expect(result).toBe(false); - }); - }); -}); - describe('hasRefId', () => { describe('when called with a null value', () => { it('then it should return null', () => { diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index f5d2d9e0072e..25f5ca982cd7 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,5 +1,6 @@ // Libraries import _ from 'lodash'; +import { Unsubscribable } from 'rxjs'; import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; // Services & Utils import { @@ -28,7 +29,6 @@ import { ExploreMode, } from 'app/types/explore'; import { config } from '../config'; -import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; export const DEFAULT_RANGE = { @@ -124,8 +124,7 @@ export function buildQueryTransaction( dashboardId: 0, // TODO probably should be taken from preferences but does not seem to be used anyway. timezone: DefaultTimeZone, - // This is set to correct time later on before the query is actually run. - startTime: 0, + startTime: Date.now(), interval, intervalMs, // TODO: the query request expects number and we are using string here. Seems like it works so far but can create @@ -409,10 +408,6 @@ export const getTimeRangeFromUrl = (range: RawTimeRange, timeZone: TimeZone): Ti }; }; -export const instanceOfDataQueryError = (value: any): value is DataQueryError => { - return value.message !== undefined && value.status !== undefined && value.statusText !== undefined; -}; - export const getValueWithRefId = (value: any): any | null => { if (!value) { return null; @@ -518,9 +513,8 @@ export const convertToWebSocketUrl = (url: string) => { return `${backend}${url}`; }; -export const stopQueryState = (queryState: PanelQueryState, reason: string) => { - if (queryState && queryState.isStarted()) { - queryState.cancel(reason); - queryState.closeStreams(false); +export const stopQueryState = (querySubscription: Unsubscribable) => { + if (querySubscription) { + querySubscription.unsubscribe(); } }; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 251cf2f19c81..bf3c379f0810 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -11,7 +11,7 @@ import { ErrorBoundary } from '@grafana/ui'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { applyPanelTimeOverrides, calculateInnerPanelHeight } from 'app/features/dashboard/utils/panel'; import { profiler } from 'app/core/profiler'; -import { getProcessedDataFrames } from '../state/PanelQueryState'; +import { getProcessedDataFrames } from '../state/runRequest'; import templateSrv from 'app/features/templating/template_srv'; import config from 'app/core/config'; @@ -82,6 +82,7 @@ export class PanelChrome extends PureComponent { componentWillUnmount() { this.props.panel.events.off('refresh', this.onRefresh); + if (this.querySubscription) { this.querySubscription.unsubscribe(); this.querySubscription = null; @@ -94,12 +95,6 @@ export class PanelChrome extends PureComponent { // View state has changed if (isInView !== prevProps.isInView) { if (isInView) { - // Subscribe will kick of a notice of the last known state - if (!this.querySubscription && this.wantsQueryExecution) { - const runner = this.props.panel.getQueryRunner(); - this.querySubscription = runner.subscribe(this.panelDataObserver); - } - // Check if we need a delayed refresh if (this.state.refreshWhenInView) { this.onRefresh(); @@ -170,8 +165,9 @@ export class PanelChrome extends PureComponent { const queryRunner = panel.getQueryRunner(); if (!this.querySubscription) { - this.querySubscription = queryRunner.subscribe(this.panelDataObserver); + this.querySubscription = queryRunner.getData().subscribe(this.panelDataObserver); } + queryRunner.run({ datasource: panel.datasource, queries: panel.targets, diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index 98d68f3d90b3..81a783196e4d 100644 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx @@ -22,10 +22,8 @@ import { DashboardModel } from '../state/DashboardModel'; import { DataQuery, DataSourceSelectItem, PanelData, AlphaNotice, PluginState } from '@grafana/ui'; import { LoadingState, DataTransformerConfig } from '@grafana/data'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; -import { PanelQueryRunnerFormat } from '../state/PanelQueryRunner'; import { Unsubscribable } from 'rxjs'; -import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; -import { DashboardQueryEditor } from 'app/plugins/datasource/dashboard/DashboardQueryEditor'; +import { isSharedDashboardQuery, DashboardQueryEditor } from 'app/plugins/datasource/dashboard'; interface Props { panel: PanelModel; @@ -64,7 +62,9 @@ export class QueriesTab extends PureComponent { const { panel } = this.props; const queryRunner = panel.getQueryRunner(); - this.querySubscription = queryRunner.subscribe(this.panelDataObserver, PanelQueryRunnerFormat.both); + this.querySubscription = queryRunner.getData(false).subscribe({ + next: (data: PanelData) => this.onPanelDataUpdate(data), + }); } componentWillUnmount() { @@ -74,22 +74,9 @@ export class QueriesTab extends PureComponent { } } - // Updates the response with information from the stream - panelDataObserver = { - next: (data: PanelData) => { - try { - const { panel } = this.props; - if (data.state === LoadingState.Error) { - panel.events.emit('data-error', data.error); - } else if (data.state === LoadingState.Done) { - panel.events.emit('data-received', data.legacy); - } - } catch (err) { - console.log('Panel.events handler error', err); - } - this.setState({ data }); - }, - }; + onPanelDataUpdate(data: PanelData) { + this.setState({ data }); + } findCurrentDataSource(): DataSourceSelectItem { const { panel } = this.props; @@ -226,11 +213,6 @@ export class QueriesTab extends PureComponent { this.setState({ scrollTop: target.scrollTop }); }; - getCurrentData = (applyTransformations = true) => { - const queryRunner = this.props.panel.getQueryRunner(); - return queryRunner.getCurrentData(applyTransformations).series; - }; - render() { const { panel, dashboard } = this.props; const { currentDS, scrollTop, data } = this.state; @@ -301,7 +283,7 @@ export class QueriesTab extends PureComponent { )} diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx index 1e28832fa635..4f5579b754ba 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx +++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx @@ -12,7 +12,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; // Types import { PanelModel } from '../state/PanelModel'; import { DataQuery, DataSourceApi, PanelData, DataQueryRequest, ErrorBoundaryAlert } from '@grafana/ui'; -import { TimeRange, LoadingState } from '@grafana/data'; +import { TimeRange, LoadingState, toLegacyResponseData } from '@grafana/data'; import { DashboardModel } from '../state/DashboardModel'; interface Props { @@ -89,7 +89,7 @@ export class QueryEditorRow extends PureComponent { componentDidUpdate(prevProps: Props) { const { loadedDataSourceValue } = this.state; - const { data, query } = this.props; + const { data, query, panel } = this.props; if (data !== prevProps.data) { this.setState({ queryResponse: filterPanelDataToQuery(data, query.refId) }); @@ -99,9 +99,7 @@ export class QueryEditorRow extends PureComponent { } if (this.angularQueryEditor) { - // Some query controllers listen to data error events and need a digest - // for some reason this needs to be done in next tick - setTimeout(this.angularQueryEditor.digest); + notifyAngularQueryEditorsOfData(panel, data, this.angularQueryEditor); } } @@ -265,6 +263,25 @@ export class QueryEditorRow extends PureComponent { } } +// To avoid sending duplicate events for each row we have this global cached object here +// So we can check if we already emitted this legacy data event +let globalLastPanelDataCache: PanelData = null; + +function notifyAngularQueryEditorsOfData(panel: PanelModel, data: PanelData, editor: AngularComponent) { + if (data === globalLastPanelDataCache) { + return; + } + + globalLastPanelDataCache = data; + + const legacy = data.series.map(v => toLegacyResponseData(v)); + panel.events.emit('data-received', legacy); + + // Some query controllers listen to data error events and need a digest + // for some reason this needs to be done in next tick + setTimeout(editor.digest); +} + export interface AngularQueryComponentScope { target: DataQuery; panel: PanelModel; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 41df0c38153c..6648ba6b3426 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -327,7 +327,8 @@ export class PanelModel { getQueryRunner(): PanelQueryRunner { if (!this.queryRunner) { - this.queryRunner = new PanelQueryRunner(this.id); + this.queryRunner = new PanelQueryRunner(); + this.setTransformations(this.transformations); } return this.queryRunner; } @@ -336,6 +337,10 @@ export class PanelModel { return this.title && this.title.length > 0; } + isAngularPlugin(): boolean { + return this.plugin && !!this.plugin.angularPanelCtrl; + } + destroy() { this.events.emit('panel-teardown'); this.events.removeAllListeners(); @@ -347,11 +352,8 @@ export class PanelModel { } setTransformations(transformations: DataTransformerConfig[]) { - // save for persistence this.transformations = transformations; - - // update query runner transformers - this.getQueryRunner().setTransform(transformations); + this.getQueryRunner().setTransformations(transformations); } } diff --git a/public/app/features/dashboard/state/PanelQueryRunner.test.ts b/public/app/features/dashboard/state/PanelQueryRunner.test.ts index 75209f54a082..0f8f55d7e398 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.test.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.test.ts @@ -1,17 +1,13 @@ -import { PanelQueryRunner, QueryRunnerOptions } from './PanelQueryRunner'; -import { PanelData, DataQueryRequest, DataStreamObserver, DataStreamState } from '@grafana/ui'; - -import { LoadingState, MutableDataFrame, ScopedVars } from '@grafana/data'; -import { dateTime } from '@grafana/data'; -import { SHARED_DASHBODARD_QUERY } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; -import { DashboardQuery } from 'app/plugins/datasource/dashboard/types'; +import { PanelQueryRunner } from './PanelQueryRunner'; +import { PanelData, DataQueryRequest } from '@grafana/ui'; +import { dateTime, ScopedVars } from '@grafana/data'; import { PanelModel } from './PanelModel'; -import { Subject } from 'rxjs'; jest.mock('app/core/services/backend_srv'); // Defined within setup functions const panelsForCurrentDashboardMock: { [key: number]: PanelModel } = {}; + jest.mock('app/features/dashboard/services/DashboardSrv', () => ({ getDashboardSrv: () => { return { @@ -40,7 +36,6 @@ interface ScenarioContext { events?: PanelData[]; res?: PanelData; queryCalledWith?: DataQueryRequest; - observer: DataStreamObserver; runner: PanelQueryRunner; } @@ -55,8 +50,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn scopedVars: { server: { text: 'Server1', value: 'server-1' }, }, - runner: new PanelQueryRunner(1), - observer: (args: any) => {}, + runner: new PanelQueryRunner(), setup: (fn: () => void) => { setupFn = fn; }, @@ -72,9 +66,8 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn const datasource: any = { name: 'TestDB', interval: ctx.dsInterval, - query: (options: DataQueryRequest, observer: DataStreamObserver) => { + query: (options: DataQueryRequest) => { ctx.queryCalledWith = options; - ctx.observer = observer; return Promise.resolve(response); }, testDatasource: jest.fn(), @@ -95,9 +88,10 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn queries: [{ refId: 'A', test: 1 }], }; - ctx.runner = new PanelQueryRunner(1); - ctx.runner.subscribe({ + ctx.runner = new PanelQueryRunner(); + ctx.runner.getData().subscribe({ next: (data: PanelData) => { + ctx.res = data; ctx.events.push(data); }, }); @@ -110,7 +104,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn } as PanelModel; ctx.events = []; - ctx.res = await ctx.runner.run(args); + ctx.runner.run(args); }); scenarioFn(ctx); @@ -190,102 +184,4 @@ describe('PanelQueryRunner', () => { expect(ctx.queryCalledWith.maxDataPoints).toBe(10); }); }); - - describeQueryRunnerScenario('when datasource is streaming data', ctx => { - let streamState: DataStreamState; - let isUnsubbed = false; - - beforeEach(() => { - streamState = { - state: LoadingState.Streaming, - key: 'test-stream-1', - data: [ - new MutableDataFrame({ - fields: [], - name: 'I am a magic stream', - }), - ], - request: { - requestId: ctx.queryCalledWith.requestId, - } as any, - unsubscribe: () => { - isUnsubbed = true; - }, - }; - ctx.observer(streamState); - }); - - it('should push another update to subscriber', async () => { - expect(ctx.events.length).toBe(2); - }); - - it('should set state to streaming', async () => { - expect(ctx.events[1].state).toBe(LoadingState.Streaming); - }); - - it('should not unsubscribe', async () => { - expect(isUnsubbed).toBe(false); - }); - - it('destroy should unsubscribe streams', async () => { - ctx.runner.destroy(); - expect(isUnsubbed).toBe(true); - }); - }); - - describeQueryRunnerScenario('Shared query request', ctx => { - ctx.setup(() => {}); - - it('should get the same results as the original', async () => { - // Get the results from - const q: DashboardQuery = { refId: 'Z', panelId: 1 }; - const myPanelId = 7; - - const runnerWantingSharedResults = new PanelQueryRunner(myPanelId); - panelsForCurrentDashboardMock[myPanelId] = { - id: myPanelId, - getQueryRunner: () => { - return runnerWantingSharedResults; - }, - } as PanelModel; - - const res = await runnerWantingSharedResults.run({ - datasource: SHARED_DASHBODARD_QUERY, - queries: [q], - - // Same query setup - scopedVars: ctx.scopedVars, - minInterval: ctx.minInterval, - widthPixels: ctx.widthPixels, - maxDataPoints: ctx.maxDataPoints, - timeRange: { - from: dateTime().subtract(1, 'days'), - to: dateTime(), - raw: { from: '1h', to: 'now' }, - }, - panelId: myPanelId, // Not 1 - }); - - const req = res.request; - expect(req.panelId).toBe(1); // The source panel - expect(req.targets[0].datasource).toBe('TestDB'); - expect(res.series.length).toBe(1); - expect(res.series[0].length).toBe(2); - - // Get the private subject and check that someone is listening - const subject = (ctx.runner as any).subject as Subject; - expect(subject.observers.length).toBe(2); - - // Now change the query and we should stop listening - try { - runnerWantingSharedResults.run({ - datasource: 'unknown-datasource', - panelId: myPanelId, // Not 1 - } as QueryRunnerOptions); - } catch {} - // runnerWantingSharedResults subject is now unsubscribed - // the test listener is still subscribed - expect(subject.observers.length).toBe(1); - }); - }); }); diff --git a/public/app/features/dashboard/state/PanelQueryRunner.ts b/public/app/features/dashboard/state/PanelQueryRunner.ts index c6b4e6bd6170..a8a5cf1f411d 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.ts @@ -1,20 +1,19 @@ // Libraries -import cloneDeep from 'lodash/cloneDeep'; -import throttle from 'lodash/throttle'; -import { Subject, Unsubscribable, PartialObserver } from 'rxjs'; +import { cloneDeep } from 'lodash'; +import { ReplaySubject, Unsubscribable, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; // Services & Utils +import { config } from 'app/core/config'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import kbn from 'app/core/utils/kbn'; import templateSrv from 'app/features/templating/template_srv'; -import { PanelQueryState } from './PanelQueryState'; -import { isSharedDashboardQuery, SharedQueryRunner } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; +import { runRequest, preProcessPanelData } from './runRequest'; +import { runSharedRequest, isSharedDashboardQuery } from '../../../plugins/datasource/dashboard'; // Types import { PanelData, DataQuery, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui'; - -import { TimeRange, DataTransformerConfig, transformDataFrame, toLegacyResponseData, ScopedVars } from '@grafana/data'; -import config from 'app/core/config'; +import { TimeRange, DataTransformerConfig, transformDataFrame, ScopedVars } from '@grafana/data'; export interface QueryRunnerOptions< TQuery extends DataQuery = DataQuery, @@ -36,109 +35,45 @@ export interface QueryRunnerOptions< transformations?: DataTransformerConfig[]; } -export enum PanelQueryRunnerFormat { - frames = 'frames', - legacy = 'legacy', - both = 'both', -} - let counter = 100; function getNextRequestId() { return 'Q' + counter++; } export class PanelQueryRunner { - private subject?: Subject; - - private state = new PanelQueryState(); + private subject?: ReplaySubject; + private subscription?: Unsubscribable; private transformations?: DataTransformerConfig[]; - // Listen to another panel for changes - private sharedQueryRunner: SharedQueryRunner; - - constructor(private panelId: number) { - this.state.onStreamingDataUpdated = this.onStreamingDataUpdated; - this.subject = new Subject(); - } - - getPanelId() { - return this.panelId; - } - - /** - * Get the last result -- optionally skip the transformation - */ - // TODO: add tests - getCurrentData(transform = true): PanelData { - const v = this.state.validateStreamsAndGetPanelData(); - const transformData = config.featureToggles.transformations && transform; - const hasTransformations = this.transformations && this.transformations.length; - - if (transformData && hasTransformations) { - const processed = transformDataFrame(this.transformations, v.series); - return { - ...v, - series: processed, - legacy: processed.map(p => toLegacyResponseData(p)), - }; - } - - return v; + constructor() { + this.subject = new ReplaySubject(1); } /** - * Listen for updates to the PanelData. If a query has already run for this panel, - * the results will be immediatly passed to the observer + * Returns an observable that subscribes to the shared multi-cast subject (that reply last result). */ - subscribe(observer: PartialObserver, format = PanelQueryRunnerFormat.frames): Unsubscribable { - if (format === PanelQueryRunnerFormat.legacy) { - this.state.sendLegacy = true; - } else if (format === PanelQueryRunnerFormat.both) { - this.state.sendFrames = true; - this.state.sendLegacy = true; - } else { - this.state.sendFrames = true; - } - - // Send the last result - if (this.state.isStarted()) { - // Force check formats again? - this.state.getDataAfterCheckingFormats(); - observer.next(this.getCurrentData()); // transformed + getData(transform = true): Observable { + if (transform) { + return this.subject.pipe( + map((data: PanelData) => { + if (this.hasTransformations()) { + const newSeries = transformDataFrame(this.transformations, data.series); + return { ...data, series: newSeries }; + } + return data; + }) + ); } - return this.subject.subscribe(observer); + // Just pass it directly + return this.subject.pipe(); } - /** - * Subscribe one runner to another - */ - chain(runner: PanelQueryRunner): Unsubscribable { - const { sendLegacy, sendFrames } = runner.state; - let format = sendFrames ? PanelQueryRunnerFormat.frames : PanelQueryRunnerFormat.legacy; - - if (sendLegacy) { - format = PanelQueryRunnerFormat.both; - } - - return this.subscribe(runner.subject, format); + hasTransformations() { + return config.featureToggles.transformations && this.transformations && this.transformations.length > 0; } - /** - * Change the current transformation and notify all listeners - * Should be used only by panel editor to update the transformers - */ - setTransform = (transformations?: DataTransformerConfig[]) => { - this.transformations = transformations; - - if (this.state.isStarted()) { - this.onStreamingDataUpdated(); - } - }; - - async run(options: QueryRunnerOptions): Promise { - const { state } = this; - + async run(options: QueryRunnerOptions) { const { queries, timezone, @@ -152,18 +87,12 @@ export class PanelQueryRunner { maxDataPoints, scopedVars, minInterval, - delayStateNotification, + // delayStateNotification, } = options; - // Support shared queries if (isSharedDashboardQuery(datasource)) { - if (!this.sharedQueryRunner) { - this.sharedQueryRunner = new SharedQueryRunner(this); - } - return this.sharedQueryRunner.process(options); - } else if (this.sharedQueryRunner) { - this.sharedQueryRunner.disconnect(); - this.sharedQueryRunner = null; + this.pipeToSubject(runSharedRequest(options)); + return; } const request: DataQueryRequest = { @@ -185,8 +114,6 @@ export class PanelQueryRunner { // Add deprecated property (request as any).rangeRaw = timeRange.raw; - let loadingStateTimeoutId = 0; - try { const ds = await getDataSource(datasource, request.scopedVars); @@ -215,54 +142,30 @@ export class PanelQueryRunner { request.interval = norm.interval; request.intervalMs = norm.intervalMs; - // Check if we can reuse the already issued query - const active = state.getActiveRunner(); - if (active) { - if (state.isSameQuery(ds, request)) { - // Maybe cancel if it has run too long? - console.log('Trying to execute query while last one has yet to complete, returning same promise'); - return active; - } else { - state.cancel('Query Changed while running'); - } - } - - // Send a loading status event on slower queries - loadingStateTimeoutId = window.setTimeout(() => { - if (state.getActiveRunner()) { - this.subject.next(this.state.validateStreamsAndGetPanelData()); - } - }, delayStateNotification || 500); - - this.transformations = options.transformations; - - const data = await state.execute(ds, request); - // Clear the delayed loading state timeout - clearTimeout(loadingStateTimeoutId); - - // Broadcast results - this.subject.next(this.getCurrentData()); - return data; + this.pipeToSubject(runRequest(ds, request)); } catch (err) { - clearTimeout(loadingStateTimeoutId); + console.log('PanelQueryRunner Error', err); + } + } - const data = state.setError(err); - this.subject.next(data); - return data; + private pipeToSubject(observable: Observable) { + if (this.subscription) { + this.subscription.unsubscribe(); } + + // Makes sure everything is a proper DataFrame + const prepare = preProcessPanelData(); + + this.subscription = observable.subscribe({ + next: (data: PanelData) => { + this.subject.next(prepare(data)); + }, + }); } - /** - * Called after every streaming event. This should be throttled so we - * avoid accidentally overwhelming the browser - */ - onStreamingDataUpdated = throttle( - () => { - this.subject.next(this.getCurrentData()); - }, - 50, - { trailing: true, leading: true } - ); + setTransformations(transformations?: DataTransformerConfig[]) { + this.transformations = transformations; + } /** * Called when the panel is closed @@ -273,17 +176,10 @@ export class PanelQueryRunner { this.subject.complete(); } - // Will cancel and disconnect any open requets - this.state.cancel('destroy'); + if (this.subscription) { + this.subscription.unsubscribe(); + } } - - setState = (state: PanelQueryState) => { - this.state = state; - }; - - getState = () => { - return this.state; - }; } async function getDataSource( diff --git a/public/app/features/dashboard/state/PanelQueryState.test.ts b/public/app/features/dashboard/state/PanelQueryState.test.ts deleted file mode 100644 index 8f6d57cfafc2..000000000000 --- a/public/app/features/dashboard/state/PanelQueryState.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { toDataQueryError, PanelQueryState, getProcessedDataFrames } from './PanelQueryState'; -import { MockDataSourceApi } from 'test/mocks/datasource_srv'; -import { LoadingState, getDataFrameRow } from '@grafana/data'; -import { DataQueryResponse, DataQueryRequest, DataQuery } from '@grafana/ui'; -import { getQueryOptions } from 'test/helpers/getQueryOptions'; - -describe('PanelQueryState', () => { - it('converts anythign to an error', () => { - let err = toDataQueryError(undefined); - expect(err.message).toEqual('Query error'); - - err = toDataQueryError('STRING ERRROR'); - expect(err.message).toEqual('STRING ERRROR'); - - err = toDataQueryError({ message: 'hello' }); - expect(err.message).toEqual('hello'); - }); - - it('keeps track of running queries', async () => { - const state = new PanelQueryState(); - expect(state.getActiveRunner()).toBeFalsy(); - let hasRun = false; - const dsRunner = new Promise((resolve, reject) => { - // The status should be running when we get here - expect(state.getActiveRunner()).toBeTruthy(); - resolve({ data: ['x', 'y'] }); - hasRun = true; - }); - const ds = new MockDataSourceApi('test'); - ds.queryResolver = dsRunner; - - // should not actually run for an empty query - let empty = await state.execute(ds, getQueryOptions({})); - expect(state.getActiveRunner()).toBeFalsy(); - expect(empty.series.length).toBe(0); - expect(hasRun).toBeFalsy(); - - const query = getQueryOptions({ - targets: [{ hide: true, refId: 'X' }, { hide: true, refId: 'Y' }, { hide: true, refId: 'Z' }], - }); - - empty = await state.execute(ds, query); - // should not run any hidden queries' - expect(state.getActiveRunner()).toBeFalsy(); - expect(empty.series.length).toBe(0); - expect(hasRun).toBeFalsy(); - - // Check for the same query - expect(state.isSameQuery(ds, query)).toBeTruthy(); - - // Check for differnet queries - expect(state.isSameQuery(new MockDataSourceApi('test'), query)).toBeFalsy(); - expect(state.isSameQuery(ds, getQueryOptions({ targets: [{ refId: 'differnet' }] }))).toBeFalsy(); - }); -}); - -describe('When cancelling request', () => { - it('Should call rejector', () => { - const state = new PanelQueryState(); - state.request = {} as DataQueryRequest; - (state as any).rejector = (obj: any) => { - expect(obj.cancelled).toBe(true); - expect(obj.message).toBe('OHH'); - }; - - state.cancel('OHH'); - }); -}); - -describe('getProcessedDataFrame', () => { - it('converts timeseries to table skipping nulls', () => { - const input1 = { - target: 'Field Name', - datapoints: [[100, 1], [200, 2]], - }; - const input2 = { - // without target - target: '', - datapoints: [[100, 1], [200, 2]], - }; - const data = getProcessedDataFrames([null, input1, input2, null, null]); - expect(data.length).toBe(2); - expect(data[0].fields[0].name).toBe(input1.target); - - const cmp = [getDataFrameRow(data[0], 0), getDataFrameRow(data[0], 1)]; - expect(cmp).toEqual(input1.datapoints); - - // Default name - expect(data[1].fields[0].name).toEqual('Value'); - - // Every colun should have a name and a type - for (const table of data) { - for (const field of table.fields) { - expect(field.name).toBeDefined(); - expect(field.type).toBeDefined(); - } - } - }); - - it('supports null values from query OK', () => { - expect(getProcessedDataFrames([null, null, null, null])).toEqual([]); - expect(getProcessedDataFrames(undefined)).toEqual([]); - expect(getProcessedDataFrames((null as unknown) as any[])).toEqual([]); - expect(getProcessedDataFrames([])).toEqual([]); - }); -}); - -function makeSeriesStub(refId: string) { - return { - fields: [{ name: undefined }], - refId, - } as any; -} - -describe('stream handling', () => { - const state = new PanelQueryState(); - state.onStreamingDataUpdated = () => { - // nothing - }; - state.request = { - requestId: '123', - range: { - raw: { - from: 123, // if string it gets revaluated - }, - }, - } as any; - state.response = { - state: LoadingState.Done, - series: [makeSeriesStub('A'), makeSeriesStub('B')], - }; - - it('gets the response', () => { - const data = state.validateStreamsAndGetPanelData(); - expect(data.series.length).toBe(2); - expect(data.state).toBe(LoadingState.Done); - expect(data.series[0].refId).toBe('A'); - }); - - it('adds a stream event', () => { - // Post a stream event - state.dataStreamObserver({ - state: LoadingState.Loading, - key: 'C', - request: state.request, // From the same request - data: [makeSeriesStub('C')], - unsubscribe: () => {}, - }); - expect(state.streams.length).toBe(1); - - const data = state.validateStreamsAndGetPanelData(); - expect(data.series.length).toBe(3); - expect(data.state).toBe(LoadingState.Streaming); - expect(data.series[2].refId).toBe('C'); - }); - - it('add another stream event (with a differnet key)', () => { - // Post a stream event - state.dataStreamObserver({ - state: LoadingState.Loading, - key: 'D', - request: state.request, // From the same request - data: [makeSeriesStub('D')], - unsubscribe: () => {}, - }); - expect(state.streams.length).toBe(2); - - const data = state.validateStreamsAndGetPanelData(); - expect(data.series.length).toBe(4); - expect(data.state).toBe(LoadingState.Streaming); - expect(data.series[3].refId).toBe('D'); - }); - - it('replace the first stream value, but keep the order', () => { - // Post a stream event - state.dataStreamObserver({ - state: LoadingState.Loading, - key: 'C', // The key to replace previous index 2 - request: state.request, // From the same request - data: [makeSeriesStub('X')], - unsubscribe: () => {}, - }); - expect(state.streams.length).toBe(2); - - const data = state.validateStreamsAndGetPanelData(); - expect(data.series[2].refId).toBe('X'); - }); - - it('ignores streams from a differnet request', () => { - // Post a stream event - state.dataStreamObserver({ - state: LoadingState.Loading, - key: 'Z', // Note with key 'A' it would still overwrite - request: { - ...state.request, - requestId: 'XXX', // Different request and id - } as any, - data: [makeSeriesStub('C')], - unsubscribe: () => {}, - }); - - expect(state.streams.length).toBe(2); // no change - const data = state.validateStreamsAndGetPanelData(); - expect(data.series.length).toBe(4); - }); - - it('removes streams when the query changes', () => { - state.request = { - ...state.request, - requestId: 'somethine else', - } as any; - state.response = { - state: LoadingState.Done, - series: [makeSeriesStub('F')], - }; - expect(state.streams.length).toBe(2); // unchanged - - const data = state.validateStreamsAndGetPanelData(); - expect(data.series.length).toBe(1); - expect(data.series[0].refId).toBe('F'); - expect(state.streams.length).toBe(0); // no streams - }); - - it('should close streams on error', () => { - // Post a stream event - state.dataStreamObserver({ - state: LoadingState.Error, - key: 'C', - error: { message: 'EEEEE' }, - data: [], - request: state.request, - unsubscribe: () => {}, - }); - - expect(state.streams.length).toBe(0); - expect(state.response.state).toBe(LoadingState.Error); - }); -}); diff --git a/public/app/features/dashboard/state/PanelQueryState.ts b/public/app/features/dashboard/state/PanelQueryState.ts deleted file mode 100644 index 6add79847e85..000000000000 --- a/public/app/features/dashboard/state/PanelQueryState.ts +++ /dev/null @@ -1,377 +0,0 @@ -// Libraries -import { isArray, isEqual, isString } from 'lodash'; -// Utils & Services -import { getBackendSrv } from 'app/core/services/backend_srv'; -import { - dateMath, - guessFieldTypes, - LoadingState, - toLegacyResponseData, - DataFrame, - toDataFrame, - isDataFrame, -} from '@grafana/data'; -// Types -import { - DataSourceApi, - DataQueryRequest, - PanelData, - DataQueryError, - DataStreamObserver, - DataStreamState, - DataQueryResponseData, -} from '@grafana/ui'; - -export class PanelQueryState { - // The current/last running request - request = { - startTime: 0, - endTime: 1000, // Somethign not zero - } as DataQueryRequest; - - // The result back from the datasource query - response = { - state: LoadingState.NotStarted, - series: [], - } as PanelData; - - // Active stream results - streams: DataStreamState[] = []; - - sendFrames = false; - sendLegacy = false; - - // A promise for the running query - private executor?: Promise = null; - private rejector = (reason?: any) => {}; - private datasource: DataSourceApi = {} as any; - - isFinished(state: LoadingState) { - return state === LoadingState.Done || state === LoadingState.Error; - } - - isStarted() { - return this.response.state !== LoadingState.NotStarted; - } - - isSameQuery(ds: DataSourceApi, req: DataQueryRequest) { - if (ds !== this.datasource) { - return false; - } - - // For now just check that the targets look the same - return isEqual(this.request.targets, req.targets); - } - - /** - * Return the currently running query - */ - getActiveRunner(): Promise | undefined { - return this.executor; - } - - cancel(reason: string) { - const { request } = this; - this.executor = null; - - try { - // If no endTime the call to datasource.query did not complete - // call rejector to reject the executor promise - if (!request.endTime) { - request.endTime = Date.now(); - this.rejector({ cancelled: true, message: reason }); - } - - // Cancel any open HTTP request with the same ID - if (request.requestId) { - getBackendSrv().resolveCancelerIfExists(request.requestId); - } - } catch (err) { - console.log('Error canceling request', err); - } - - // Close any open streams - this.closeStreams(true); - } - - execute(ds: DataSourceApi, req: DataQueryRequest): Promise { - this.request = { - ...req, - startTime: Date.now(), - }; - this.datasource = ds; - - // Return early if there are no queries to run - if (!req.targets.length) { - console.log('No queries, so return early'); - this.request.endTime = Date.now(); - this.closeStreams(); - return Promise.resolve( - (this.response = { - state: LoadingState.Done, - series: [], // Clear the data - legacy: [], - }) - ); - } - - // Set the loading state immediately - this.response.state = LoadingState.Loading; - this.executor = new Promise((resolve, reject) => { - this.rejector = reject; - - return ds - .query(this.request, this.dataStreamObserver) - .then(resp => { - if (!isArray(resp.data)) { - throw new Error(`Expected response data to be array, got ${typeof resp.data}.`); - } - - this.request.endTime = Date.now(); - this.executor = null; - - // Make sure we send something back -- called run() w/o subscribe! - if (!(this.sendFrames || this.sendLegacy)) { - this.sendFrames = true; - } - - // Save the result state - this.response = { - state: LoadingState.Done, - request: this.request, - series: this.sendFrames ? getProcessedDataFrames(resp.data) : [], - legacy: this.sendLegacy ? translateToLegacyData(resp.data) : undefined, - }; - resolve(this.validateStreamsAndGetPanelData()); - }) - .catch(err => { - this.executor = null; - resolve(this.setError(err)); - }); - }); - - return this.executor; - } - - // Send a notice when the stream has updated the current model - onStreamingDataUpdated: () => void; - - // This gets all stream events and keeps track of them - // it will then delegate real changes to the PanelQueryRunner - dataStreamObserver: DataStreamObserver = (stream: DataStreamState) => { - // Streams only work with the 'series' format - this.sendFrames = true; - - if (stream.state === LoadingState.Error) { - this.setError(stream.error); - this.onStreamingDataUpdated(); - return; - } - - // Add the stream to our list - let found = false; - const active = this.streams.map(s => { - if (s.key === stream.key) { - found = true; - return stream; - } - return s; - }); - - if (!found) { - if (shouldDisconnect(this.request, stream)) { - console.log('Got stream update from old stream, unsubscribing'); - stream.unsubscribe(); - return; - } - active.push(stream); - } - - this.streams = active; - this.onStreamingDataUpdated(); - }; - - closeStreams(keepSeries = false) { - if (!this.streams.length) { - return; - } - - const series: DataFrame[] = []; - - for (const stream of this.streams) { - if (stream.data) { - series.push.apply(series, stream.data); - } - - try { - stream.unsubscribe(); - } catch { - console.log('Failed to unsubscribe to stream'); - } - } - - this.streams = []; - - // Move the series from streams to the response - if (keepSeries) { - const { response } = this; - this.response = { - ...response, - series: [ - ...response.series, - ...series, // Append the streamed series - ], - }; - } - } - - /** - * This is called before broadcasting data to listeners. Given that - * stream events can happen at any point, we need to make sure to - * only return data from active streams. - */ - validateStreamsAndGetPanelData(): PanelData { - const { response, streams, request } = this; - - // When not streaming, return the response + request - if (!streams.length) { - return { - ...response, - request: request, - }; - } - - let done = this.isFinished(response.state); - const series = [...response.series]; - const active: DataStreamState[] = []; - - for (const stream of this.streams) { - if (shouldDisconnect(request, stream)) { - console.log('getPanelData() - shouldDisconnect true, unsubscribing to steam'); - stream.unsubscribe(); - continue; - } - - active.push(stream); - series.push.apply(series, stream.data); - - if (!this.isFinished(stream.state)) { - done = false; - } - } - - this.streams = active; - - // Update the time range - let timeRange = this.request.range; - if (isString(timeRange.raw.from)) { - timeRange = { - from: dateMath.parse(timeRange.raw.from, false), - to: dateMath.parse(timeRange.raw.to, true), - raw: timeRange.raw, - }; - } - - return { - state: done ? LoadingState.Done : LoadingState.Streaming, - // This should not be needed but unfortunately Prometheus datasource sends non DataFrame here bypassing the - // typings - series: this.sendFrames ? getProcessedDataFrames(series) : [], - legacy: this.sendLegacy ? translateToLegacyData(series) : undefined, - request: { - ...this.request, - range: timeRange, // update the time range - }, - }; - } - - /** - * Make sure all requested formats exist on the data - */ - getDataAfterCheckingFormats(): PanelData { - const { response, sendLegacy, sendFrames } = this; - if (sendLegacy && (!response.legacy || !response.legacy.length)) { - response.legacy = response.series.map(v => toLegacyResponseData(v)); - } - if (sendFrames && !response.series.length && response.legacy) { - response.series = response.legacy.map(v => toDataFrame(v)); - } - return this.validateStreamsAndGetPanelData(); - } - - setError(err: any): PanelData { - if (!this.request.endTime) { - this.request.endTime = Date.now(); - } - this.closeStreams(true); - this.response = { - ...this.response, // Keep any existing data - state: LoadingState.Error, - error: toDataQueryError(err), - }; - return this.validateStreamsAndGetPanelData(); - } -} - -export function shouldDisconnect(source: DataQueryRequest, state: DataStreamState) { - // It came from the same the same request, so keep it - if (source === state.request || state.request.requestId.startsWith(source.requestId)) { - return false; - } - - // We should be able to check that it is the same query regardless of - // if it came from the same request. This will be important for #16676 - - return true; -} - -export function toDataQueryError(err: any): DataQueryError { - const error = (err || {}) as DataQueryError; - if (!error.message) { - if (typeof err === 'string' || err instanceof String) { - return { message: err } as DataQueryError; - } - - let message = 'Query error'; - if (error.message) { - message = error.message; - } else if (error.data && error.data.message) { - message = error.data.message; - } else if (error.data && error.data.error) { - message = error.data.error; - } else if (error.status) { - message = `Query error: ${error.status} ${error.statusText}`; - } - error.message = message; - } - return error; -} - -function translateToLegacyData(data: DataQueryResponseData) { - return data.map((v: any) => { - if (isDataFrame(v)) { - return toLegacyResponseData(v); - } - return v; - }); -} - -/** - * All panels will be passed tables that have our best guess at colum type set - * - * This is also used by PanelChrome for snapshot support - */ -export function getProcessedDataFrames(results?: DataQueryResponseData[]): DataFrame[] { - if (!isArray(results)) { - return []; - } - - const series: DataFrame[] = []; - for (const r of results) { - if (r) { - series.push(guessFieldTypes(toDataFrame(r))); - } - } - - return series; -} diff --git a/public/app/features/dashboard/state/runRequest.test.ts b/public/app/features/dashboard/state/runRequest.test.ts new file mode 100644 index 000000000000..f305c1bd693f --- /dev/null +++ b/public/app/features/dashboard/state/runRequest.test.ts @@ -0,0 +1,206 @@ +import { DataFrame, LoadingState, dateTime } from '@grafana/data'; +import { PanelData, DataSourceApi, DataQueryRequest, DataQueryResponse } from '@grafana/ui'; +import { Subscriber, Observable, Subscription } from 'rxjs'; +import { runRequest } from './runRequest'; + +jest.mock('app/core/services/backend_srv'); + +class ScenarioCtx { + ds: DataSourceApi; + request: DataQueryRequest; + subscriber: Subscriber; + isUnsubbed = false; + setupFn: () => void = () => {}; + results: PanelData[]; + subscription: Subscription; + wasStarted = false; + error: Error = null; + toStartTime = dateTime(); + fromStartTime = dateTime(); + + reset() { + this.wasStarted = false; + this.isUnsubbed = false; + + this.results = []; + this.request = { + range: { + from: this.toStartTime, + to: this.fromStartTime, + raw: { from: '1h', to: 'now' }, + }, + targets: [ + { + refId: 'A', + }, + ], + } as DataQueryRequest; + + this.ds = { + query: (request: DataQueryRequest) => { + return new Observable(subscriber => { + this.subscriber = subscriber; + this.wasStarted = true; + + if (this.error) { + throw this.error; + } + + return () => { + this.isUnsubbed = true; + }; + }); + }, + } as DataSourceApi; + } + + start() { + this.subscription = runRequest(this.ds, this.request).subscribe({ + next: (data: PanelData) => { + this.results.push(data); + }, + }); + } + + emitPacket(packet: DataQueryResponse) { + this.subscriber.next(packet); + } + + setup(fn: () => void) { + this.setupFn = fn; + } +} + +function runRequestScenario(desc: string, fn: (ctx: ScenarioCtx) => void) { + describe(desc, () => { + const ctx = new ScenarioCtx(); + + beforeEach(() => { + ctx.reset(); + return ctx.setupFn(); + }); + + fn(ctx); + }); +} + +describe('runRequest', () => { + runRequestScenario('with no queries', ctx => { + ctx.setup(() => { + ctx.request.targets = []; + ctx.start(); + }); + + it('should emit empty result with loading state done', () => { + expect(ctx.wasStarted).toBe(false); + expect(ctx.results[0].state).toBe(LoadingState.Done); + }); + }); + + runRequestScenario('After first response', ctx => { + ctx.setup(() => { + ctx.start(); + ctx.emitPacket({ + data: [{ name: 'Data' } as DataFrame], + }); + }); + + it('should emit single result with loading state done', () => { + expect(ctx.wasStarted).toBe(true); + expect(ctx.results.length).toBe(1); + }); + }); + + runRequestScenario('After tree responses, 2 with different keys', ctx => { + ctx.setup(() => { + ctx.start(); + ctx.emitPacket({ + data: [{ name: 'DataA-1' } as DataFrame], + key: 'A', + }); + ctx.emitPacket({ + data: [{ name: 'DataA-2' } as DataFrame], + key: 'A', + }); + ctx.emitPacket({ + data: [{ name: 'DataB-1' } as DataFrame], + key: 'B', + }); + }); + + it('should emit 3 seperate results', () => { + expect(ctx.results.length).toBe(3); + }); + + it('should combine results and return latest data for key A', () => { + expect(ctx.results[2].series).toEqual([{ name: 'DataA-2' }, { name: 'DataB-1' }]); + }); + + it('should have loading state Done', () => { + expect(ctx.results[2].state).toEqual(LoadingState.Done); + }); + }); + + runRequestScenario('After response with state Streaming', ctx => { + ctx.setup(() => { + ctx.start(); + ctx.emitPacket({ + data: [{ name: 'DataA-1' } as DataFrame], + key: 'A', + }); + ctx.emitPacket({ + data: [{ name: 'DataA-2' } as DataFrame], + key: 'A', + state: LoadingState.Streaming, + }); + }); + + it('should have loading state Streaming', () => { + expect(ctx.results[1].state).toEqual(LoadingState.Streaming); + }); + }); + + runRequestScenario('If no response after 250ms', ctx => { + ctx.setup(async () => { + ctx.start(); + await sleep(250); + }); + + it('should emit 1 result with loading state', () => { + expect(ctx.results.length).toBe(1); + expect(ctx.results[0].state).toBe(LoadingState.Loading); + }); + }); + + runRequestScenario('on thrown error', ctx => { + ctx.setup(() => { + ctx.error = new Error('Ohh no'); + ctx.start(); + }); + + it('should emit 1 error result', () => { + expect(ctx.results[0].error.message).toBe('Ohh no'); + expect(ctx.results[0].state).toBe(LoadingState.Error); + }); + }); + + runRequestScenario('If time range is relative', ctx => { + ctx.setup(async () => { + ctx.start(); + // wait a bit + await sleep(20); + + ctx.emitPacket({ data: [{ name: 'DataB-1' } as DataFrame] }); + }); + + it('should update returned request range', () => { + expect(ctx.results[0].request.range.to.valueOf()).not.toBe(ctx.fromStartTime); + }); + }); +}); + +async function sleep(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/public/app/features/dashboard/state/runRequest.ts b/public/app/features/dashboard/state/runRequest.ts new file mode 100644 index 000000000000..9a41a060ec93 --- /dev/null +++ b/public/app/features/dashboard/state/runRequest.ts @@ -0,0 +1,211 @@ +// Libraries +import { Observable, of, timer, merge, from } from 'rxjs'; +import { flatten, map as lodashMap, isArray, isString } from 'lodash'; +import { map, catchError, takeUntil, mapTo, share, finalize } from 'rxjs/operators'; +// Utils & Services +import { getBackendSrv } from 'app/core/services/backend_srv'; +// Types +import { + DataSourceApi, + DataQueryRequest, + PanelData, + DataQueryResponse, + DataQueryResponseData, + DataQueryError, +} from '@grafana/ui'; + +import { LoadingState, dateMath, toDataFrame, DataFrame, guessFieldTypes } from '@grafana/data'; + +type MapOfResponsePackets = { [str: string]: DataQueryResponse }; + +interface RunningQueryState { + packets: { [key: string]: DataQueryResponse }; + panelData: PanelData; +} + +/* + * This function should handle composing a PanelData from multiple responses + */ +export function processResponsePacket(packet: DataQueryResponse, state: RunningQueryState): RunningQueryState { + const request = state.panelData.request; + const packets: MapOfResponsePackets = { + ...state.packets, + }; + + packets[packet.key || 'A'] = packet; + + // Update the time range + let timeRange = request.range; + if (isString(timeRange.raw.from)) { + timeRange = { + from: dateMath.parse(timeRange.raw.from, false), + to: dateMath.parse(timeRange.raw.to, true), + raw: timeRange.raw, + }; + } + + const combinedData = flatten( + lodashMap(packets, (packet: DataQueryResponse) => { + return packet.data; + }) + ); + + const panelData = { + state: packet.state || LoadingState.Done, + series: combinedData, + request: { + ...request, + range: timeRange, + }, + }; + + return { packets, panelData }; +} + +/** + * This function handles the excecution of requests & and processes the single or multiple response packets into + * a combined PanelData response. + * It will + * * Merge multiple responses into a single DataFrame array based on the packet key + * * Will emit a loading state if no response after 50ms + * * Cancel any still runnning network requests on unsubscribe (using request.requestId) + */ +export function runRequest(datasource: DataSourceApi, request: DataQueryRequest): Observable { + let state: RunningQueryState = { + panelData: { + state: LoadingState.Loading, + series: [], + request: request, + }, + packets: {}, + }; + + // Return early if there are no queries to run + if (!request.targets.length) { + request.endTime = Date.now(); + state.panelData.state = LoadingState.Done; + return of(state.panelData); + } + + const dataObservable = callQueryMethod(datasource, request).pipe( + // Transform response packets into PanelData with merged results + map((packet: DataQueryResponse) => { + if (!isArray(packet.data)) { + throw new Error(`Expected response data to be array, got ${typeof packet.data}.`); + } + + request.endTime = Date.now(); + + state = processResponsePacket(packet, state); + return state.panelData; + }), + // handle errors + catchError(err => + of({ + ...state.panelData, + state: LoadingState.Error, + error: processQueryError(err), + }) + ), + // finalize is triggered when subscriber unsubscribes + // This makes sure any still running network requests are cancelled + finalize(cancelNetworkRequestsOnUnsubscribe(request)), + // this makes it possible to share this observable in takeUntil + share() + ); + + // If 50ms without a response emit a loading state + // mapTo will translate the timer event into state.panelData (which has state set to loading) + // takeUntil will cancel the timer emit when first response packet is received on the dataObservable + return merge( + timer(200).pipe( + mapTo(state.panelData), + takeUntil(dataObservable) + ), + dataObservable + ); +} + +function cancelNetworkRequestsOnUnsubscribe(req: DataQueryRequest) { + return () => { + getBackendSrv().resolveCancelerIfExists(req.requestId); + }; +} + +export function callQueryMethod(datasource: DataSourceApi, request: DataQueryRequest) { + const returnVal = datasource.query(request); + return from(returnVal); +} + +export function processQueryError(err: any): DataQueryError { + const error = (err || {}) as DataQueryError; + + if (!error.message) { + if (typeof err === 'string' || err instanceof String) { + return { message: err } as DataQueryError; + } + + let message = 'Query error'; + if (error.message) { + message = error.message; + } else if (error.data && error.data.message) { + message = error.data.message; + } else if (error.data && error.data.error) { + message = error.data.error; + } else if (error.status) { + message = `Query error: ${error.status} ${error.statusText}`; + } + error.message = message; + } + + return error; +} + +/** + * All panels will be passed tables that have our best guess at colum type set + * + * This is also used by PanelChrome for snapshot support + */ +export function getProcessedDataFrames(results?: DataQueryResponseData[]): DataFrame[] { + if (!isArray(results)) { + return []; + } + + const dataFrames: DataFrame[] = []; + + for (const result of results) { + const dataFrame = guessFieldTypes(toDataFrame(result)); + + // clear out any cached calcs + for (const field of dataFrame.fields) { + field.calcs = null; + } + + dataFrames.push(dataFrame); + } + + return dataFrames; +} + +export function preProcessPanelData() { + let lastResult: PanelData = null; + + return function mapper(data: PanelData) { + let { series } = data; + + // for loading states with no data, use last result + if (data.state === LoadingState.Loading && series.length === 0) { + if (!lastResult) { + lastResult = data; + } + + return { ...lastResult, state: LoadingState.Loading }; + } + + // Makes sure the data is properly formatted + series = getProcessedDataFrames(series); + + lastResult = { ...data, series }; + return lastResult; + }; +} diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index 30c4df246b76..03ce58839062 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -1,4 +1,5 @@ // Types +import { Unsubscribable } from 'rxjs'; import { Emitter } from 'app/core/core'; import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, PanelData } from '@grafana/ui'; @@ -128,6 +129,11 @@ export interface QueryEndedPayload { response: PanelData; } +export interface QueryStoreSubscriptionPayload { + exploreId: ExploreId; + querySubscription: Unsubscribable; +} + export interface HistoryUpdatedPayload { exploreId: ExploreId; history: HistoryItem[]; @@ -307,6 +313,10 @@ export const queryStreamUpdatedAction = actionCreatorFactory( 'explore/QUERY_STREAM_UPDATED' ).create(); +export const queryStoreSubscriptionAction = actionCreatorFactory( + 'explore/QUERY_STORE_SUBSCRIPTION' +).create(); + /** * Remove query row of the given index, as well as associated query results. */ diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index 675e465dd567..f5fb5091082c 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -1,4 +1,5 @@ // Libraries +import { map } from 'rxjs/operators'; // Services & Utils import store from 'app/core/store'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -63,21 +64,20 @@ import { loadExploreDatasources, changeModeAction, scanStopAction, - queryStartAction, setUrlReplacedAction, changeRangeAction, historyUpdatedAction, - queryEndedAction, queryStreamUpdatedAction, + queryStoreSubscriptionAction, clearOriginAction, } from './actionTypes'; import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; import { getShiftedTimeRange } from 'app/core/utils/timePicker'; -import _ from 'lodash'; import { updateLocation } from '../../../core/actions'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; +import { runRequest, preProcessPanelData } from '../../dashboard/state/runRequest'; /** * Updates UI state and save it to the URL @@ -436,12 +436,14 @@ export function runQueries(exploreId: ExploreId): ThunkResult { datasourceError, containerWidth, isLive: live, - queryState, queryIntervals, range, scanning, + querySubscription, history, mode, + showingGraph, + showingTable, } = exploreItemState; if (datasourceError) { @@ -459,10 +461,7 @@ export function runQueries(exploreId: ExploreId): ThunkResult { // but we're using the datasource interval limit for now const interval = datasourceInstance.interval; - stopQueryState(queryState, 'New request issued'); - - queryState.sendFrames = true; - queryState.sendLegacy = true; + stopQueryState(querySubscription); const queryOptions = { interval, @@ -470,32 +469,32 @@ export function runQueries(exploreId: ExploreId): ThunkResult { // TODO: not sure if this makes sense for normal query when using both graph and table maxDataPoints: mode === ExploreMode.Logs ? 1000 : containerWidth, live, + showingGraph, + showingTable, }; + const datasourceId = datasourceInstance.meta.id; const transaction = buildQueryTransaction(queries, queryOptions, range, queryIntervals, scanning); - queryState.onStreamingDataUpdated = () => { - const response = queryState.validateStreamsAndGetPanelData(); - dispatch(queryStreamUpdatedAction({ exploreId, response })); - }; - - dispatch(queryStartAction({ exploreId })); + let firstResponse = true; - queryState - .execute(datasourceInstance, transaction.request) - .then((response: PanelData) => { - if (!response.error) { + const newQuerySub = runRequest(datasourceInstance, transaction.request) + .pipe(map(preProcessPanelData())) + .subscribe((data: PanelData) => { + if (!data.error && firstResponse) { // Side-effect: Saving history in localstorage const nextHistory = updateHistory(history, datasourceId, queries); dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); + dispatch(stateSave()); } - dispatch(queryEndedAction({ exploreId, response })); - dispatch(stateSave()); + firstResponse = false; + + dispatch(queryStreamUpdatedAction({ exploreId, response: data })); // Keep scanning for results if this was the last scanning transaction if (getState().explore[exploreId].scanning) { - if (_.size(response.series) === 0) { + if (data.state === LoadingState.Done && data.series.length === 0) { const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); dispatch(updateTime({ exploreId, absoluteRange: range })); dispatch(runQueries(exploreId)); @@ -504,15 +503,9 @@ export function runQueries(exploreId: ExploreId): ThunkResult { dispatch(scanStopAction({ exploreId })); } } - }) - .catch(error => { - dispatch( - queryEndedAction({ - exploreId, - response: { error, legacy: [], series: [], request: transaction.request, state: LoadingState.Error }, - }) - ); }); + + dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub })); }; } diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 18005d2def44..bf09628ef708 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -18,6 +18,8 @@ import { splitCloseAction, changeModeAction, scanStopAction, + toggleGraphAction, + toggleTableAction, } from './actionTypes'; import { Reducer } from 'redux'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; @@ -26,14 +28,12 @@ import { serializeStateToUrlParam } from 'app/core/utils/explore'; import TableModel from 'app/core/table_model'; import { DataSourceApi, DataQuery } from '@grafana/ui'; import { LogsModel, LogsDedupStrategy } from '@grafana/data'; -import { PanelQueryState } from '../../dashboard/state/PanelQueryState'; describe('Explore item reducer', () => { describe('scanning', () => { it('should start scanning', () => { const initalState = { ...makeExploreItemState(), - queryState: null as PanelQueryState, scanning: false, }; @@ -42,14 +42,12 @@ describe('Explore item reducer', () => { .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), - queryState: null as PanelQueryState, scanning: true, }); }); it('should stop scanning', () => { const initalState = { ...makeExploreItemState(), - queryState: null as PanelQueryState, scanning: true, scanRange: {}, }; @@ -59,7 +57,6 @@ describe('Explore item reducer', () => { .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), - queryState: null as PanelQueryState, scanning: false, scanRange: undefined, }); @@ -175,6 +172,30 @@ describe('Explore item reducer', () => { }); }); }); + + describe('toggling panels', () => { + describe('when toggleGraphAction is dispatched', () => { + it('then it should set correct state', () => { + reducerTester() + .givenReducer(itemReducer, { graphResult: [] }) + .whenActionIsDispatched(toggleGraphAction({ exploreId: ExploreId.left })) + .thenStateShouldEqual({ showingGraph: true, graphResult: [] }) + .whenActionIsDispatched(toggleGraphAction({ exploreId: ExploreId.left })) + .thenStateShouldEqual({ showingGraph: false, graphResult: null }); + }); + }); + + describe('when toggleTableAction is dispatched', () => { + it('then it should set correct state', () => { + reducerTester() + .givenReducer(itemReducer, { tableResult: {} }) + .whenActionIsDispatched(toggleTableAction({ exploreId: ExploreId.left })) + .thenStateShouldEqual({ showingTable: true, tableResult: {} }) + .whenActionIsDispatched(toggleTableAction({ exploreId: ExploreId.left })) + .thenStateShouldEqual({ showingTable: false, tableResult: new TableModel() }); + }); + }); + }); }); export const setup = (urlStateOverrides?: any) => { diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 7828a76fff9a..1d8b0d2471f3 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -8,10 +8,9 @@ import { sortLogsResult, stopQueryState, refreshIntervalToSortOrder, - instanceOfDataQueryError, } from 'app/core/utils/explore'; import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore'; -import { LoadingState } from '@grafana/data'; +import { LoadingState, toLegacyResponseData } from '@grafana/data'; import { DataQuery, DataSourceApi, PanelData } from '@grafana/ui'; import { HigherOrderAction, @@ -29,9 +28,6 @@ import { queryStartAction, changeRangeAction, clearOriginAction, -} from './actionTypes'; - -import { addQueryRowAction, changeQueryAction, changeSizeAction, @@ -53,17 +49,17 @@ import { toggleLogLevelAction, changeLoadingStateAction, resetExploreAction, - queryEndedAction, queryStreamUpdatedAction, QueryEndedPayload, + queryStoreSubscriptionAction, setPausedStateAction, + toggleGraphAction, } from './actionTypes'; import { reducerFactory, ActionOf } from 'app/core/redux'; import { updateLocation } from 'app/core/actions/location'; import { LocationUpdate } from '@grafana/runtime'; import TableModel from 'app/core/table_model'; import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; -import { PanelQueryState, toDataQueryError } from '../../dashboard/state/PanelQueryState'; import { ResultProcessor } from '../utils/ResultProcessor'; export const DEFAULT_RANGE = { @@ -121,7 +117,6 @@ export const makeExploreItemState = (): ExploreItemState => ({ isLive: false, isPaused: false, urlReplaced: false, - queryState: new PanelQueryState(), queryResponse: createEmptyQueryResponse(), }); @@ -129,7 +124,6 @@ export const createEmptyQueryResponse = (): PanelData => ({ state: LoadingState.NotStarted, request: null, series: [], - legacy: null, error: null, }); @@ -203,8 +197,9 @@ export const itemReducer = reducerFactory({} as ExploreItemSta const live = isLive(refreshInterval); const sortOrder = refreshIntervalToSortOrder(refreshInterval); const logsResult = sortLogsResult(state.logsResult, sortOrder); + if (isLive(state.refreshInterval) && !live) { - stopQueryState(state.queryState, 'Live streaming stopped'); + stopQueryState(state.querySubscription); } return { @@ -225,7 +220,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta filter: clearQueriesAction, mapper: (state): ExploreItemState => { const queries = ensureQueries(); - stopQueryState(state.queryState, 'Queries cleared'); + stopQueryState(state.querySubscription); return { ...state, queries: queries.slice(), @@ -284,7 +279,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta // Custom components const StartPage = datasourceInstance.components.ExploreStartPage; - stopQueryState(state.queryState, 'Datasource changed'); + stopQueryState(state.querySubscription); return { ...state, @@ -440,15 +435,26 @@ export const itemReducer = reducerFactory({} as ExploreItemSta return { ...state, ...action.payload }; }, }) + .addMapper({ + filter: toggleGraphAction, + mapper: (state): ExploreItemState => { + const showingGraph = !state.showingGraph; + if (showingGraph) { + return { ...state, showingGraph }; + } + + return { ...state, showingGraph, graphResult: null }; + }, + }) .addMapper({ filter: toggleTableAction, mapper: (state): ExploreItemState => { const showingTable = !state.showingTable; if (showingTable) { - return { ...state }; + return { ...state, showingTable }; } - return { ...state, tableResult: new TableModel() }; + return { ...state, showingTable, tableResult: new TableModel() }; }, }) .addMapper({ @@ -566,10 +572,13 @@ export const itemReducer = reducerFactory({} as ExploreItemSta }, }) .addMapper({ - //queryStreamUpdatedAction - filter: queryEndedAction, + filter: queryStoreSubscriptionAction, mapper: (state, action): ExploreItemState => { - return processQueryResponse(state, action); + const { querySubscription } = action.payload; + return { + ...state, + querySubscription, + }; }, }) .addMapper({ @@ -585,7 +594,7 @@ export const processQueryResponse = ( action: ActionOf ): ExploreItemState => { const { response } = action.payload; - const { request, state: loadingState, series, legacy, error } = response; + const { request, state: loadingState, series, error } = response; if (error) { if (error.cancelled) { @@ -595,12 +604,6 @@ export const processQueryResponse = ( // For Angular editors state.eventBridge.emit('data-error', error); - console.error(error); // To help finding problems with query syntax - - if (!instanceOfDataQueryError(error)) { - response.error = toDataQueryError(error); - } - return { ...state, loading: false, @@ -613,19 +616,26 @@ export const processQueryResponse = ( }; } - const latency = request.endTime - request.startTime; + const latency = request.endTime ? request.endTime - request.startTime : 0; const processor = new ResultProcessor(state, series); + const graphResult = processor.getGraphResult() || state.graphResult; // don't replace results until we receive new results + const tableResult = processor.getTableResult() || state.tableResult || new TableModel(); // don't replace results until we receive new results + const logsResult = processor.getLogsResult(); - // For Angular editors - state.eventBridge.emit('data-received', legacy); + // Send legacy data to Angular editors + if (state.datasourceInstance.components.QueryCtrl) { + const legacy = series.map(v => toLegacyResponseData(v)); + + state.eventBridge.emit('data-received', legacy); + } return { ...state, latency, queryResponse: response, - graphResult: processor.getGraphResult(), - tableResult: processor.getTableResult(), - logsResult: processor.getLogsResult(), + graphResult, + tableResult, + logsResult, loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming, showingStartPage: false, update: makeInitialUpdateState(), @@ -757,8 +767,8 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA case resetExploreAction.type: { const leftState = state[ExploreId.left]; const rightState = state[ExploreId.right]; - stopQueryState(leftState.queryState, 'Navigated away from Explore'); - stopQueryState(rightState.queryState, 'Navigated away from Explore'); + stopQueryState(leftState.querySubscription); + stopQueryState(rightState.querySubscription); return { ...state, diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index faa649d83acb..9fa187ea45ea 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -67,20 +67,20 @@ const testContext = (options: any = {}) => { describe('ResultProcessor', () => { describe('constructed without result', () => { describe('when calling getGraphResult', () => { - it('then it should return an empty array', () => { + it('then it should return null', () => { const { resultProcessor } = testContext({ dataFrames: [] }); const theResult = resultProcessor.getGraphResult(); - expect(theResult).toEqual([]); + expect(theResult).toEqual(null); }); }); describe('when calling getTableResult', () => { - it('then it should return an empty TableModel', () => { + it('then it should return null', () => { const { resultProcessor } = testContext({ dataFrames: [] }); const theResult = resultProcessor.getTableResult(); - expect(theResult).toEqual(new TableModel()); + expect(theResult).toEqual(null); }); }); diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 6633e9238ccb..45fe31cb0103 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/public/app/features/explore/utils/ResultProcessor.ts @@ -11,11 +11,15 @@ export class ResultProcessor { getGraphResult(): GraphSeriesXY[] { if (this.state.mode !== ExploreMode.Metrics) { - return []; + return null; } const onlyTimeSeries = this.dataFrames.filter(isTimeSeries); + if (onlyTimeSeries.length === 0) { + return null; + } + return getGraphSeriesModel( onlyTimeSeries, {}, @@ -26,7 +30,7 @@ export class ResultProcessor { getTableResult(): TableModel { if (this.state.mode !== ExploreMode.Metrics) { - return new TableModel(); + return null; } // For now ignore time series @@ -34,6 +38,10 @@ export class ResultProcessor { // Ignore time series only for prometheus const onlyTables = this.dataFrames.filter(frame => !isTimeSeries(frame)); + if (onlyTables.length === 0) { + return null; + } + const tables = onlyTables.map(frame => { const { fields } = frame; const fieldCount = fields.length; diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index edf93c204615..ee3c81e44f2a 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -6,12 +6,11 @@ import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import { getExploreUrl } from 'app/core/utils/explore'; import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel'; import { ContextSrv } from 'app/core/services/context_srv'; -import { toLegacyResponseData, isDataFrame, TimeRange, LoadingState, DataFrame, toDataFrameDTO } from '@grafana/data'; +import { toLegacyResponseData, TimeRange, LoadingState, DataFrame, toDataFrameDTO } from '@grafana/data'; import { LegacyResponseData, DataSourceApi, PanelData, DataQueryResponse } from '@grafana/ui'; import { Unsubscribable } from 'rxjs'; import { PanelModel } from 'app/features/dashboard/state'; -import { PanelQueryRunnerFormat } from '../dashboard/state/PanelQueryRunner'; class MetricsPanelCtrl extends PanelCtrl { scope: any; @@ -30,7 +29,7 @@ class MetricsPanelCtrl extends PanelCtrl { skipDataOnInit: boolean; dataList: LegacyResponseData[]; querySubscription?: Unsubscribable; - dataFormat = PanelQueryRunnerFormat.legacy; + useDataFrames = false; constructor($scope: any, $injector: any) { super($scope, $injector); @@ -141,22 +140,12 @@ class MetricsPanelCtrl extends PanelCtrl { } } - if (this.dataFormat === PanelQueryRunnerFormat.legacy) { - // The result should already be processed, but just in case - if (!data.legacy) { - data.legacy = data.series.map(v => { - if (isDataFrame(v)) { - return toLegacyResponseData(v); - } - return v; - }); - } - - // Make the results look like they came directly from a <6.2 datasource request - // NOTE: any object other than 'data' is no longer supported supported - this.handleQueryResult({ data: data.legacy }); - } else { + if (this.useDataFrames) { this.handleDataFrames(data.series); + } else { + // Make the results look as if they came directly from a <6.2 datasource request + const legacy = data.series.map(v => toLegacyResponseData(v)); + this.handleQueryResult({ data: legacy }); } }, }; @@ -197,7 +186,7 @@ class MetricsPanelCtrl extends PanelCtrl { const queryRunner = panel.getQueryRunner(); if (!this.querySubscription) { - this.querySubscription = queryRunner.subscribe(this.panelDataObserver, this.dataFormat); + this.querySubscription = queryRunner.getData().subscribe(this.panelDataObserver); } return queryRunner.run({ diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index 7434f0cf363d..925b55dd6dec 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -9,7 +9,7 @@ import config from 'app/core/config'; import { css } from 'emotion'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PanelModel } from 'app/features/dashboard/state'; -import { SHARED_DASHBODARD_QUERY } from './SharedQueryRunner'; +import { SHARED_DASHBODARD_QUERY } from './types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { filterPanelDataToQuery } from 'app/features/dashboard/panel_editor/QueryEditorRow'; diff --git a/public/app/plugins/datasource/dashboard/SharedQueryRunner.ts b/public/app/plugins/datasource/dashboard/SharedQueryRunner.ts deleted file mode 100644 index 1a56b4dac344..000000000000 --- a/public/app/plugins/datasource/dashboard/SharedQueryRunner.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { DataSourceApi, DataQuery, PanelData } from '@grafana/ui'; -import { PanelQueryRunner, QueryRunnerOptions } from 'app/features/dashboard/state/PanelQueryRunner'; -import { toDataQueryError } from 'app/features/dashboard/state/PanelQueryState'; -import { DashboardQuery } from './types'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { Unsubscribable } from 'rxjs'; -import { PanelModel } from 'app/features/dashboard/state'; -import { LoadingState } from '@grafana/data'; - -export const SHARED_DASHBODARD_QUERY = '-- Dashboard --'; - -export function isSharedDashboardQuery(datasource: string | DataSourceApi) { - if (!datasource) { - // default datasource - return false; - } - if (datasource === SHARED_DASHBODARD_QUERY) { - return true; - } - const ds = datasource as DataSourceApi; - return ds.meta && ds.meta.name === SHARED_DASHBODARD_QUERY; -} - -export class SharedQueryRunner { - private containerPanel: PanelModel; - private listenToPanelId: number; - private listenToPanel: PanelModel; - private listenToRunner: PanelQueryRunner; - private subscription: Unsubscribable; - - constructor(private runner: PanelQueryRunner) { - this.containerPanel = getDashboardSrv() - .getCurrent() - .getPanelById(runner.getPanelId()); - } - - process(options: QueryRunnerOptions): Promise { - const panelId = getPanelIdFromQuery(options.queries); - - if (!panelId) { - this.disconnect(); - return getQueryError('Missing panel reference ID'); - } - - // The requested panel changed - if (this.listenToPanelId !== panelId) { - this.disconnect(); - - this.listenToPanel = getDashboardSrv() - .getCurrent() - .getPanelById(panelId); - - if (!this.listenToPanel) { - return getQueryError('Unknown Panel: ' + panelId); - } - - this.listenToPanelId = panelId; - this.listenToRunner = this.listenToPanel.getQueryRunner(); - this.subscription = this.listenToRunner.chain(this.runner); - this.runner.setState(this.listenToRunner.getState()); - console.log('Connecting panel: ', this.containerPanel.id, 'to:', this.listenToPanelId); - } - - // If the target has refreshed recently, use the exising data - const data = this.listenToRunner.getCurrentData(); - if (data.request && data.request.startTime) { - const elapsed = Date.now() - data.request.startTime; - if (elapsed < 150) { - return Promise.resolve(data); - } - } - - // When fullscreen run with the current panel settings - if (this.containerPanel.fullscreen) { - const { datasource, targets } = this.listenToPanel; - const modified = { - ...options, - panelId, - datasource, - queries: targets, - }; - return this.listenToRunner.run(modified); - } else { - this.listenToPanel.refresh(); - } - - return Promise.resolve(data); - } - - disconnect() { - if (this.subscription) { - this.subscription.unsubscribe(); - this.subscription = null; - } - if (this.listenToPanel) { - this.listenToPanel = null; - } - this.listenToPanelId = undefined; - } -} - -function getPanelIdFromQuery(queries: DataQuery[]): number | undefined { - if (!queries || !queries.length) { - return undefined; - } - return (queries[0] as DashboardQuery).panelId; -} - -function getQueryError(msg: string): Promise { - return Promise.resolve({ - state: LoadingState.Error, - series: [], - legacy: [], - error: toDataQueryError(msg), - }); -} diff --git a/public/app/plugins/datasource/dashboard/index.ts b/public/app/plugins/datasource/dashboard/index.ts new file mode 100644 index 000000000000..23645ba6a91b --- /dev/null +++ b/public/app/plugins/datasource/dashboard/index.ts @@ -0,0 +1,3 @@ +export { isSharedDashboardQuery, runSharedRequest } from './runSharedRequest'; +export { DashboardQueryEditor } from './DashboardQueryEditor'; +export { SHARED_DASHBODARD_QUERY } from './types'; diff --git a/public/app/plugins/datasource/dashboard/SharedQueryRunner.test.ts b/public/app/plugins/datasource/dashboard/runSharedRequest.test.ts similarity index 90% rename from public/app/plugins/datasource/dashboard/SharedQueryRunner.test.ts rename to public/app/plugins/datasource/dashboard/runSharedRequest.test.ts index 6ae758ac3e0a..2a3e17fb87f3 100644 --- a/public/app/plugins/datasource/dashboard/SharedQueryRunner.test.ts +++ b/public/app/plugins/datasource/dashboard/runSharedRequest.test.ts @@ -1,4 +1,4 @@ -import { isSharedDashboardQuery } from './SharedQueryRunner'; +import { isSharedDashboardQuery } from './runSharedRequest'; import { DataSourceApi } from '@grafana/ui'; describe('SharedQueryRunner', () => { diff --git a/public/app/plugins/datasource/dashboard/runSharedRequest.ts b/public/app/plugins/datasource/dashboard/runSharedRequest.ts new file mode 100644 index 000000000000..603cd65a7af6 --- /dev/null +++ b/public/app/plugins/datasource/dashboard/runSharedRequest.ts @@ -0,0 +1,80 @@ +import { Observable } from 'rxjs'; +import { DataQuery, PanelData, DataSourceApi } from '@grafana/ui'; +import { QueryRunnerOptions } from 'app/features/dashboard/state/PanelQueryRunner'; +import { DashboardQuery } from './types'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { LoadingState } from '@grafana/data'; +import { SHARED_DASHBODARD_QUERY } from './types'; + +export function isSharedDashboardQuery(datasource: string | DataSourceApi) { + if (!datasource) { + // default datasource + return false; + } + if (datasource === SHARED_DASHBODARD_QUERY) { + return true; + } + const ds = datasource as DataSourceApi; + return ds.meta && ds.meta.name === SHARED_DASHBODARD_QUERY; +} + +export function runSharedRequest(options: QueryRunnerOptions): Observable { + return new Observable(subscriber => { + const dashboard = getDashboardSrv().getCurrent(); + const listenToPanelId = getPanelIdFromQuery(options.queries); + + if (!listenToPanelId) { + subscriber.next(getQueryError('Missing panel reference ID')); + return null; + } + + const currentPanel = dashboard.getPanelById(options.panelId); + const listenToPanel = dashboard.getPanelById(listenToPanelId); + + if (!listenToPanel) { + subscriber.next(getQueryError('Unknown Panel: ' + listenToPanelId)); + return null; + } + + const listenToRunner = listenToPanel.getQueryRunner(); + const subscription = listenToRunner.getData(false).subscribe({ + next: (data: PanelData) => { + console.log('got data from other panel', data); + subscriber.next(data); + }, + }); + + // If we are in fullscreen the other panel will not execute any queries + // So we have to trigger it from here + if (currentPanel.fullscreen) { + const { datasource, targets } = listenToPanel; + const modified = { + ...options, + datasource, + panelId: listenToPanelId, + queries: targets, + }; + listenToRunner.run(modified); + } + + return () => { + console.log('runSharedRequest unsubscribe'); + subscription.unsubscribe(); + }; + }); +} + +function getPanelIdFromQuery(queries: DataQuery[]): number | undefined { + if (!queries || !queries.length) { + return undefined; + } + return (queries[0] as DashboardQuery).panelId; +} + +function getQueryError(msg: string): PanelData { + return { + state: LoadingState.Error, + series: [], + error: { message: msg }, + }; +} diff --git a/public/app/plugins/datasource/dashboard/types.ts b/public/app/plugins/datasource/dashboard/types.ts index 6207165a7974..c9c558053396 100644 --- a/public/app/plugins/datasource/dashboard/types.ts +++ b/public/app/plugins/datasource/dashboard/types.ts @@ -1,5 +1,7 @@ import { DataQuery } from '@grafana/ui/src/types'; +export const SHARED_DASHBODARD_QUERY = '-- Dashboard --'; + export interface DashboardQuery extends DataQuery { panelId?: number; } diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index c42391343826..66507c2211a3 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -71,7 +71,7 @@ describe('LokiDatasource', () => { targets: [{ expr: '{} foo', refId: 'B' }], }); - const res = await ds.query(options); + const res = await ds.query(options).toPromise(); const dataFrame = res.data[0] as DataFrame; expect(dataFrame.fields[1].values.get(0)).toBe('hello'); diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index e0cc078c4290..f04c56017611 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -5,10 +5,10 @@ import { dateMath, DataFrame, LogRowModel, - LoadingState, DateTime, AnnotationEvent, DataFrameView, + LoadingState, } from '@grafana/data'; import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query'; import LanguageProvider from './language_provider'; @@ -21,7 +21,6 @@ import { DataSourceInstanceSettings, DataQueryError, DataQueryRequest, - DataStreamObserver, DataQueryResponse, AnnotationQueryRequest, } from '@grafana/ui'; @@ -31,6 +30,8 @@ import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { safeStringifyValue, convertToWebSocketUrl } from 'app/core/utils/explore'; import { LiveTarget, LiveStreams } from './live_streams'; +import { Observable, from, merge } from 'rxjs'; +import { map, filter } from 'rxjs/operators'; export const DEFAULT_MAX_LINES = 1000; @@ -167,79 +168,51 @@ export class LokiDatasource extends DataSourceApi { return series; }; - runLiveQueries = (options: DataQueryRequest, observer?: DataStreamObserver) => { - const liveTargets = options.targets - .filter(target => target.expr && !target.hide && target.live) - .map(target => this.prepareLiveTarget(target, options)); - - for (const liveTarget of liveTargets) { - // Reuse an existing stream if one is already running - const stream = this.streams.getStream(liveTarget); - const subscription = stream.subscribe({ - next: (data: DataFrame[]) => { - observer({ - key: `loki-${liveTarget.refId}`, - request: options, - state: LoadingState.Streaming, - data, - unsubscribe: () => { - subscription.unsubscribe(); - }, - }); - }, - error: (err: any) => { - observer({ - key: `loki-${liveTarget.refId}`, - request: options, - state: LoadingState.Error, - error: this.processError(err, liveTarget), - unsubscribe: () => { - subscription.unsubscribe(); - }, - }); - }, - }); - } + runLiveQuery = (options: DataQueryRequest, target: LokiQuery): Observable => { + const liveTarget = this.prepareLiveTarget(target, options); + const stream = this.streams.getStream(liveTarget); + return stream.pipe( + map(data => { + return { + data, + key: `loki-${liveTarget.refId}`, + state: LoadingState.Streaming, + }; + }) + ); }; - runQueries = async (options: DataQueryRequest): Promise<{ data: DataFrame[] }> => { - const queryTargets = options.targets - .filter(target => target.expr && !target.hide && !target.live) - .map(target => this.prepareQueryTarget(target, options)); - - if (queryTargets.length === 0) { - return Promise.resolve({ data: [] }); - } - - const queries = queryTargets.map(target => - this._request('/api/prom/query', target).catch((err: any) => { + runQuery = (options: DataQueryRequest, target: LokiQuery): Observable => { + const query = this.prepareQueryTarget(target, options); + return from( + this._request('/api/prom/query', query).catch((err: any) => { if (err.cancelled) { return err; } - const error: DataQueryError = this.processError(err, target); + const error: DataQueryError = this.processError(err, query); throw error; }) + ).pipe( + filter((response: any) => (response.cancelled ? false : true)), + map((response: any) => { + const data = this.processResult(response.data, query); + return { data, key: query.refId }; + }) ); - - return Promise.all(queries).then((results: any[]) => { - let series: DataFrame[] = []; - - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.data) { - series = series.concat(this.processResult(result.data, queryTargets[i])); - } - } - - return { data: series }; - }); }; - async query(options: DataQueryRequest, observer?: DataStreamObserver) { - this.runLiveQueries(options, observer); + query(options: DataQueryRequest): Observable { + const subQueries = options.targets + .filter(target => target.expr && !target.hide) + .map(target => { + if (target.live) { + return this.runLiveQuery(options, target); + } + return this.runQuery(options, target); + }); - return this.runQueries(options); + return merge(...subQueries); } async importQueries(queries: LokiQuery[], originMeta: PluginMeta): Promise { @@ -383,12 +356,14 @@ export class LokiDatasource extends DataSourceApi { return []; } - const query = queryRequestFromAnnotationOptions(options); - const { data } = await this.runQueries(query); + const request = queryRequestFromAnnotationOptions(options); + const { data } = await this.runQuery(request, request.targets[0]).toPromise(); const annotations: AnnotationEvent[] = []; + for (const frame of data) { - const tags = Object.values(frame.labels); + const tags = Object.values(frame.labels) as string[]; const view = new DataFrameView<{ ts: string; line: string }>(frame); + view.forEachRow(row => { annotations.push({ time: new Date(row.ts).valueOf(), diff --git a/public/app/plugins/datasource/mixed/MixedDataSource.test.ts b/public/app/plugins/datasource/mixed/MixedDataSource.test.ts index 3d25daee15f1..c20aab66f375 100644 --- a/public/app/plugins/datasource/mixed/MixedDataSource.test.ts +++ b/public/app/plugins/datasource/mixed/MixedDataSource.test.ts @@ -3,6 +3,7 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { getQueryOptions } from 'test/helpers/getQueryOptions'; import { DataSourceInstanceSettings } from '@grafana/ui'; import { MixedDatasource } from './module'; +import { from } from 'rxjs'; const defaultDS = new MockDataSourceApi('DefaultDS', { data: ['DDD'] }); const datasourceSrv = new DatasourceSrvMock(defaultDS, { @@ -26,10 +27,19 @@ describe('MixedDatasource', () => { { refId: 'QC', datasource: 'C' }, // 3 ], }); + const results: any[] = []; - it('direct query should return results', async () => { + beforeEach(async () => { const ds = await getDataSourceSrv().get('-- Mixed --'); - const res = await ds.query(requestMixed); - expect(res.data).toEqual(['AAAA', 'BBBB', 'CCCC']); + from(ds.query(requestMixed)).subscribe(result => { + results.push(result); + }); + }); + + it('direct query should return results', async () => { + expect(results.length).toBe(3); + expect(results[0].data).toEqual(['AAAA']); + expect(results[1].data).toEqual(['BBBB']); + expect(results[2].data).toEqual(['CCCC']); }); }); diff --git a/public/app/plugins/datasource/mixed/MixedDataSource.ts b/public/app/plugins/datasource/mixed/MixedDataSource.ts index 5462d1e11d94..0962bc1c498e 100644 --- a/public/app/plugins/datasource/mixed/MixedDataSource.ts +++ b/public/app/plugins/datasource/mixed/MixedDataSource.ts @@ -1,19 +1,10 @@ import cloneDeep from 'lodash/cloneDeep'; import groupBy from 'lodash/groupBy'; -import map from 'lodash/map'; -import flatten from 'lodash/flatten'; -import filter from 'lodash/filter'; - -import { - DataSourceApi, - DataQuery, - DataQueryRequest, - DataQueryResponse, - DataStreamObserver, - DataSourceInstanceSettings, -} from '@grafana/ui'; +import { from, of, Observable, merge } from 'rxjs'; +import { DataSourceApi, DataQuery, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings } from '@grafana/ui'; import { getDataSourceSrv } from '@grafana/runtime'; +import { mergeMap, map, filter } from 'rxjs/operators'; export const MIXED_DATASOURCE_NAME = '-- Mixed --'; @@ -22,43 +13,68 @@ export class MixedDatasource extends DataSourceApi { super(instanceSettings); } - async query(request: DataQueryRequest, observer: DataStreamObserver): Promise { + query(request: DataQueryRequest): Observable { // Remove any invalid queries const queries = request.targets.filter(t => { return t.datasource !== MIXED_DATASOURCE_NAME; }); if (!queries.length) { - return Promise.resolve({ data: [] }); // nothing + return of({ data: [] } as DataQueryResponse); // nothing } - const sets = groupBy(queries, 'datasource'); + const sets: { [key: string]: DataQuery[] } = groupBy(queries, 'datasource'); + const observables: Array> = []; - const promises = map(sets, (targets: DataQuery[]) => { + for (const key in sets) { + const targets = sets[key]; const dsName = targets[0].datasource; - return getDataSourceSrv() - .get(dsName) - .then((ds: DataSourceApi) => { - const opt = cloneDeep(request); + + const observable = from(getDataSourceSrv().get(dsName)).pipe( + map((dataSourceApi: DataSourceApi) => { + const datasourceRequest = cloneDeep(request); // Remove any unused hidden queries - if (!ds.meta.hiddenQueries) { - targets = filter(targets, (t: DataQuery) => { - return !t.hide; - }); - if (targets.length === 0) { - return { data: [] }; - } + let newTargets = targets.slice(); + if (!dataSourceApi.meta.hiddenQueries) { + newTargets = newTargets.filter((t: DataQuery) => !t.hide); } - opt.targets = targets; - return ds.query(opt); - }); - }); + datasourceRequest.targets = newTargets; + datasourceRequest.requestId = `${dsName}${datasourceRequest.requestId || ''}`; + return { + dataSourceApi, + datasourceRequest, + }; + }) + ); - return Promise.all(promises).then(results => { - return { data: flatten(map(results, 'data')) }; - }); + const noTargets = observable.pipe( + filter(({ datasourceRequest }) => datasourceRequest.targets.length === 0), + mergeMap(() => { + return of({ data: [] } as DataQueryResponse); + }) + ); + + const hasTargets = observable.pipe( + filter(({ datasourceRequest }) => datasourceRequest.targets.length > 0), + mergeMap(({ dataSourceApi, datasourceRequest }) => { + return from(dataSourceApi.query(datasourceRequest)).pipe( + map((response: DataQueryResponse) => { + return { + ...response, + data: response.data || [], + key: `${dsName}${response.key || ''}`, + } as DataQueryResponse; + }) + ); + }) + ); + + observables.push(merge(noTargets, hasTargets)); + } + + return merge(...observables); } testDatasource() { diff --git a/public/app/plugins/datasource/postgres/query_ctrl.ts b/public/app/plugins/datasource/postgres/query_ctrl.ts index 22982dd4fd8e..e0c539347215 100644 --- a/public/app/plugins/datasource/postgres/query_ctrl.ts +++ b/public/app/plugins/datasource/postgres/query_ctrl.ts @@ -293,6 +293,7 @@ export class PostgresQueryCtrl extends QueryCtrl { onDataReceived(dataList: any) { this.lastQueryMeta = null; this.lastQueryError = null; + console.log('postgres query data received', dataList); const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId }); if (anySeriesFromQuery) { diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 100301bad916..98ac07ff94de 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -3,9 +3,9 @@ import _ from 'lodash'; import $ from 'jquery'; // Services & Utils import kbn from 'app/core/utils/kbn'; -import { dateMath, TimeRange, DateTime, AnnotationEvent, LoadingState } from '@grafana/data'; -import { Observable, from, of } from 'rxjs'; -import { single, filter, mergeMap, catchError } from 'rxjs/operators'; +import { dateMath, TimeRange, DateTime, AnnotationEvent } from '@grafana/data'; +import { Observable, from, of, merge } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import PrometheusMetricFindQuery from './metric_find_query'; import { ResultTransformer } from './result_transformer'; @@ -21,14 +21,14 @@ import { DataSourceApi, DataSourceInstanceSettings, DataQueryError, - DataStreamObserver, DataQueryResponseData, - DataStreamState, + DataQueryResponse, } from '@grafana/ui'; import { safeStringifyValue } from 'app/core/utils/explore'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { ExploreUrlState } from 'app/types'; +import { LoadingState } from '@grafana/data/src/types/data'; export interface PromDataQueryResponse { data: { @@ -174,61 +174,6 @@ export class PrometheusDatasource extends DataSourceApi return series; }; - runObserverQueries = ( - options: DataQueryRequest, - observer: DataStreamObserver, - queries: PromQueryRequest[], - activeTargets: PromQuery[], - end: number - ) => { - for (let index = 0; index < queries.length; index++) { - const query = queries[index]; - const target = activeTargets[index]; - let observable: Observable = null; - - if (query.instant) { - observable = from(this.performInstantQuery(query, end)); - } else { - observable = from(this.performTimeSeriesQuery(query, query.start, query.end)); - } - - observable - .pipe( - single(), // unsubscribes automatically after first result - filter((response: any) => (response.cancelled ? false : true)), - mergeMap((response: any) => { - const data = this.processResult(response, query, target, queries.length); - const state: DataStreamState = { - key: `prometheus-${target.refId}`, - state: LoadingState.Done, - request: options, - // TODO this is obviously wrong as data is not a DataFrame and needs to be dealt with later on - // in PanelQueryState - data: data as any, - unsubscribe: () => undefined, - }; - - return [state]; - }), - catchError(err => { - const error = this.handleErrors(err, target); - const state: DataStreamState = { - key: `prometheus-${target.refId}`, - request: options, - state: LoadingState.Error, - error, - unsubscribe: () => undefined, - }; - - return of(state); - }) - ) - .subscribe({ - next: state => observer(state), - }); - } - }; - prepareTargets = (options: DataQueryRequest, start: number, end: number) => { const queries: PromQueryRequest[] = []; const activeTargets: PromQuery[] = []; @@ -238,22 +183,35 @@ export class PrometheusDatasource extends DataSourceApi continue; } - if (target.context === PromContext.Explore) { - target.format = 'time_series'; - target.instant = false; + target.requestId = options.panelId + target.refId; + + if (target.context !== PromContext.Explore) { + activeTargets.push(target); + queries.push(this.createQuery(target, options, start, end)); + continue; + } + + if (target.showingTable) { + // create instant target only if Table is showed in Explore const instantTarget: any = _.cloneDeep(target); instantTarget.format = 'table'; instantTarget.instant = true; instantTarget.valueWithRefId = true; delete instantTarget.maxDataPoints; instantTarget.requestId += '_instant'; - instantTarget.refId += '_instant'; + activeTargets.push(instantTarget); queries.push(this.createQuery(instantTarget, options, start, end)); } - activeTargets.push(target); - queries.push(this.createQuery(target, options, start, end)); + if (target.showingGraph) { + // create time series target only if Graph is showed in Explore + target.format = 'time_series'; + target.instant = false; + + activeTargets.push(target); + queries.push(this.createQuery(target, options, start, end)); + } } return { @@ -262,54 +220,44 @@ export class PrometheusDatasource extends DataSourceApi }; }; - query(options: DataQueryRequest, observer?: DataStreamObserver): Promise<{ data: any }> { + query(options: DataQueryRequest): Observable { const start = this.getPrometheusTime(options.range.from, false); const end = this.getPrometheusTime(options.range.to, true); - - options = _.clone(options); const { queries, activeTargets } = this.prepareTargets(options, start, end); // No valid targets, return the empty result to save a round trip. if (_.isEmpty(queries)) { - return this.$q.when({ data: [] }) as Promise<{ data: any }>; + return of({ data: [] }); } - if ( - observer && - options.targets.filter(target => target.context === PromContext.Explore).length === options.targets.length - ) { - // using observer to make the instant query return immediately - this.runObserverQueries(options, observer, queries, activeTargets, end); - return this.$q.when({ data: [] }) as Promise<{ data: any }>; - } + const allInstant = queries.filter(query => query.instant).length === queries.length; + const allTimeSeries = queries.filter(query => !query.instant).length === queries.length; + const subQueries = queries.map((query, index) => { + const target = activeTargets[index]; + let observable: Observable = null; + const state: LoadingState = + allInstant || allTimeSeries ? LoadingState.Done : query.instant ? LoadingState.Loading : LoadingState.Done; - const allQueryPromise = _.map(queries, query => { if (query.instant) { - return this.performInstantQuery(query, end); + observable = from(this.performInstantQuery(query, end)); } else { - return this.performTimeSeriesQuery(query, query.start, query.end); + observable = from(this.performTimeSeriesQuery(query, query.start, query.end)); } - }); - const allPromise = this.$q.all(allQueryPromise).then((responseList: any) => { - let result: any[] = []; - - _.each(responseList, (response, index: number) => { - if (response.cancelled) { - return; - } - - const target = activeTargets[index]; - const query = queries[index]; - const series = this.processResult(response, query, target, queries.length); - - result = [...result, ...series]; - }); - - return { data: result }; + return observable.pipe( + filter((response: any) => (response.cancelled ? false : true)), + map((response: any) => { + const data = this.processResult(response, query, target, queries.length); + return { + data, + key: query.requestId, + state, + } as DataQueryResponse; + }) + ); }); - return allPromise as Promise<{ data: any }>; + return merge(...subQueries); } createQuery(target: PromQuery, options: DataQueryRequest, start: number, end: number) { @@ -318,8 +266,8 @@ export class PrometheusDatasource extends DataSourceApi instant: target.instant, step: 0, expr: '', - requestId: '', - refId: '', + requestId: target.requestId, + refId: target.refId, start: 0, end: 0, }; @@ -361,8 +309,6 @@ export class PrometheusDatasource extends DataSourceApi // Only replace vars in expression after having (possibly) updated interval vars query.expr = this.templateSrv.replace(expr, scopedVars, this.interpolateQueryExpr); - query.requestId = options.panelId + target.refId; - query.refId = target.refId; // Align query interval with step to allow query caching and to ensure // that about-same-time query results look the same. @@ -520,9 +466,17 @@ export class PrometheusDatasource extends DataSourceApi ...options, interval: step, }; + // Unsetting min interval for accurate event resolution const minStep = '1s'; - const query = this.createQuery({ expr, interval: minStep, refId: 'X' }, queryOptions, start, end); + const queryModel = { + expr, + interval: minStep, + refId: 'X', + requestId: `prom-query-${annotation.name}`, + }; + + const query = this.createQuery(queryModel, queryOptions, start, end); const self = this; return this.performTimeSeriesQuery(query, query.start, query.end).then((results: PromDataQueryResponse) => { diff --git a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts index 1ad026bd3499..ab2680904c0f 100644 --- a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts @@ -9,8 +9,8 @@ import { prometheusSpecialRegexEscape, } from '../datasource'; import { dateTime } from '@grafana/data'; -import { DataSourceInstanceSettings, DataQueryResponseData } from '@grafana/ui'; -import { PromOptions } from '../types'; +import { DataSourceInstanceSettings, DataQueryResponseData, DataQueryRequest } from '@grafana/ui'; +import { PromOptions, PromQuery, PromContext } from '../types'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { CustomVariable } from 'app/features/templating/custom_variable'; @@ -179,8 +179,8 @@ describe('PrometheusDatasource', () => { }, ]; - ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue(responseMock); - return ctx.ds.query(ctx.query).then((result: any) => { + ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue([responseMock]); + ctx.ds.query(ctx.query).subscribe((result: any) => { const results = result.data; return expect(results).toMatchObject(expected); }); @@ -209,8 +209,8 @@ describe('PrometheusDatasource', () => { const expected = ['1', '2', '4', '+Inf']; - ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue(responseMock); - return ctx.ds.query(ctx.query).then((result: any) => { + ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue([responseMock]); + ctx.ds.query(ctx.query).subscribe((result: any) => { const seriesLabels = _.map(result.data, 'target'); return expect(seriesLabels).toEqual(expected); }); @@ -469,7 +469,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query).then((data: any) => { + ctx.ds.query(query).subscribe((data: any) => { results = data; }); }); @@ -503,18 +503,14 @@ describe('PrometheusDatasource', () => { }, }; - beforeEach(async () => { + it('should generate an error', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.reject(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - - await ctx.ds.query(query).catch((e: any) => { + ctx.ds.query(query).subscribe((e: any) => { results = e.message; + expect(results).toBe(`"${errMessage}"`); }); }); - - it('should generate an error', () => { - expect(results).toBe(`"${errMessage}"`); - }); }); }); @@ -553,7 +549,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query).then((data: any) => { + ctx.ds.query(query).subscribe((data: any) => { results = data; }); }); @@ -614,8 +610,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - - await ctx.ds.query(query).then((data: any) => { + ctx.ds.query(query).subscribe((data: any) => { results = data; }); }); @@ -816,7 +811,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query).then((data: any) => { + ctx.ds.query(query).subscribe((data: any) => { results = data; }); }); @@ -853,7 +848,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -869,7 +864,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test&start=60&end=420&step=1'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -890,7 +885,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test&start=60&end=420&step=10'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -907,7 +902,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test&start=' + start + '&end=' + end + '&step=2'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -929,7 +924,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test&start=50&end=400&step=50'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -950,7 +945,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=60&end=420&step=15'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -972,7 +967,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=0&end=400&step=100'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -994,7 +989,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=' + start + '&end=' + end + '&step=100'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1016,7 +1011,7 @@ describe('PrometheusDatasource', () => { const urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=' + start + '&end=' + end + '&step=60'; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1059,7 +1054,7 @@ describe('PrometheusDatasource', () => { templateSrv.replace = jest.fn(str => str); backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1099,7 +1094,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); templateSrv.replace = jest.fn(str => str); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1140,7 +1135,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); templateSrv.replace = jest.fn(str => str); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1187,7 +1182,7 @@ describe('PrometheusDatasource', () => { templateSrv.replace = jest.fn(str => str); backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1228,7 +1223,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1274,7 +1269,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); templateSrv.replace = jest.fn(str => str); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.method).toBe('GET'); expect(res.url).toBe(urlExpected); @@ -1326,7 +1321,7 @@ describe('PrometheusDatasource', () => { templateSrv.replace = jest.fn(str => str); backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query); + ctx.ds.query(query); const res = backendSrv.datasourceRequest.mock.calls[0][0]; expect(res.url).toBe(urlExpected); @@ -1391,7 +1386,7 @@ describe('PrometheusDatasource for POST', () => { }; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); - await ctx.ds.query(query).then((data: any) => { + ctx.ds.query(query).subscribe((data: any) => { results = data; }); }); @@ -1432,3 +1427,211 @@ describe('PrometheusDatasource for POST', () => { }); }); }); + +const getPrepareTargetsContext = (target: PromQuery) => { + const instanceSettings = ({ + url: 'proxied', + directUrl: 'direct', + user: 'test', + password: 'mupp', + jsonData: { httpMethod: 'POST' }, + } as unknown) as DataSourceInstanceSettings; + const start = 0; + const end = 1; + const panelId = '2'; + const options = ({ targets: [target], interval: '1s', panelId } as any) as DataQueryRequest; + + const ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv as any, timeSrv as any); + const { queries, activeTargets } = ds.prepareTargets(options, start, end); + + return { + queries, + activeTargets, + start, + end, + panelId, + }; +}; + +describe('prepareTargets', () => { + describe('when run from a Panel', () => { + it('then it should just add targets', () => { + const target: PromQuery = { + refId: 'A', + expr: 'up', + context: PromContext.Panel, + }; + + const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target); + + expect(queries.length).toBe(1); + expect(activeTargets.length).toBe(1); + expect(queries[0]).toEqual({ + end, + expr: 'up', + headers: { + 'X-Dashboard-Id': undefined, + 'X-Panel-Id': panelId, + }, + hinting: undefined, + instant: undefined, + refId: target.refId, + requestId: panelId + target.refId, + start, + step: 1, + }); + expect(activeTargets[0]).toEqual(target); + }); + }); + + describe('when run from Explore', () => { + describe('and both Graph and Table are shown', () => { + it('then it should return both instant and time series related objects', () => { + const target: PromQuery = { + refId: 'A', + expr: 'up', + context: PromContext.Explore, + showingGraph: true, + showingTable: true, + }; + + const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target); + + expect(queries.length).toBe(2); + expect(activeTargets.length).toBe(2); + expect(queries[0]).toEqual({ + end, + expr: 'up', + headers: { + 'X-Dashboard-Id': undefined, + 'X-Panel-Id': panelId, + }, + hinting: undefined, + instant: true, + refId: target.refId, + requestId: panelId + target.refId + '_instant', + start, + step: 1, + }); + expect(activeTargets[0]).toEqual({ + ...target, + format: 'table', + instant: true, + requestId: panelId + target.refId + '_instant', + valueWithRefId: true, + }); + expect(queries[1]).toEqual({ + end, + expr: 'up', + headers: { + 'X-Dashboard-Id': undefined, + 'X-Panel-Id': panelId, + }, + hinting: undefined, + instant: false, + refId: target.refId, + requestId: panelId + target.refId, + start, + step: 1, + }); + expect(activeTargets[1]).toEqual({ + ...target, + format: 'time_series', + instant: false, + requestId: panelId + target.refId, + }); + }); + }); + describe('and both Graph and Table are hidden', () => { + it('then it should return empty arrays', () => { + const target: PromQuery = { + refId: 'A', + expr: 'up', + context: PromContext.Explore, + showingGraph: false, + showingTable: false, + }; + + const { queries, activeTargets } = getPrepareTargetsContext(target); + + expect(queries.length).toBe(0); + expect(activeTargets.length).toBe(0); + }); + }); + + describe('and Graph is hidden', () => { + it('then it should return only intant related objects', () => { + const target: PromQuery = { + refId: 'A', + expr: 'up', + context: PromContext.Explore, + showingGraph: false, + showingTable: true, + }; + + const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target); + + expect(queries.length).toBe(1); + expect(activeTargets.length).toBe(1); + expect(queries[0]).toEqual({ + end, + expr: 'up', + headers: { + 'X-Dashboard-Id': undefined, + 'X-Panel-Id': panelId, + }, + hinting: undefined, + instant: true, + refId: target.refId, + requestId: panelId + target.refId + '_instant', + start, + step: 1, + }); + expect(activeTargets[0]).toEqual({ + ...target, + format: 'table', + instant: true, + requestId: panelId + target.refId + '_instant', + valueWithRefId: true, + }); + }); + }); + + describe('and Table is hidden', () => { + it('then it should return only time series related objects', () => { + const target: PromQuery = { + refId: 'A', + expr: 'up', + context: PromContext.Explore, + showingGraph: true, + showingTable: false, + }; + + const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target); + + expect(queries.length).toBe(1); + expect(activeTargets.length).toBe(1); + expect(queries[0]).toEqual({ + end, + expr: 'up', + headers: { + 'X-Dashboard-Id': undefined, + 'X-Panel-Id': panelId, + }, + hinting: undefined, + instant: false, + refId: target.refId, + requestId: panelId + target.refId, + start, + step: 1, + }); + expect(activeTargets[0]).toEqual({ + ...target, + format: 'time_series', + instant: false, + requestId: panelId + target.refId, + }); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index dc548ae13ea0..387b9c9e3d0b 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -15,6 +15,9 @@ export interface PromQuery extends DataQuery { intervalFactor?: number; legendFormat?: string; valueWithRefId?: boolean; + requestId?: string; + showingGraph?: boolean; + showingTable?: boolean; } export interface PromOptions extends DataSourceJsonData { diff --git a/public/app/plugins/datasource/testdata/LogIpsum.ts b/public/app/plugins/datasource/testdata/LogIpsum.ts new file mode 100644 index 000000000000..a1afe7023fbe --- /dev/null +++ b/public/app/plugins/datasource/testdata/LogIpsum.ts @@ -0,0 +1,162 @@ +import { LogLevel } from '@grafana/data'; + +let index = 0; + +export function getRandomLogLevel(): LogLevel { + const v = Math.random(); + if (v > 0.9) { + return LogLevel.critical; + } + if (v > 0.8) { + return LogLevel.error; + } + if (v > 0.7) { + return LogLevel.warning; + } + if (v > 0.4) { + return LogLevel.info; + } + if (v > 0.3) { + return LogLevel.debug; + } + if (v > 0.1) { + return LogLevel.trace; + } + return LogLevel.unknown; +} + +export function getNextWord() { + index = (index + Math.floor(Math.random() * 5)) % words.length; + return words[index]; +} + +export function getRandomLine(length = 60) { + let line = getNextWord(); + while (line.length < length) { + line += ' ' + getNextWord(); + } + return line; +} + +const words = [ + 'At', + 'vero', + 'eos', + 'et', + 'accusamus', + 'et', + 'iusto', + 'odio', + 'dignissimos', + 'ducimus', + 'qui', + 'blanditiis', + 'praesentium', + 'voluptatum', + 'deleniti', + 'atque', + 'corrupti', + 'quos', + 'dolores', + 'et', + 'quas', + 'molestias', + 'excepturi', + 'sint', + 'occaecati', + 'cupiditate', + 'non', + 'provident', + 'similique', + 'sunt', + 'in', + 'culpa', + 'qui', + 'officia', + 'deserunt', + 'mollitia', + 'animi', + 'id', + 'est', + 'laborum', + 'et', + 'dolorum', + 'fuga', + 'Et', + 'harum', + 'quidem', + 'rerum', + 'facilis', + 'est', + 'et', + 'expedita', + 'distinctio', + 'Nam', + 'libero', + 'tempore', + 'cum', + 'soluta', + 'nobis', + 'est', + 'eligendi', + 'optio', + 'cumque', + 'nihil', + 'impedit', + 'quo', + 'minus', + 'id', + 'quod', + 'maxime', + 'placeat', + 'facere', + 'possimus', + 'omnis', + 'voluptas', + 'assumenda', + 'est', + 'omnis', + 'dolor', + 'repellendus', + 'Temporibus', + 'autem', + 'quibusdam', + 'et', + 'aut', + 'officiis', + 'debitis', + 'aut', + 'rerum', + 'necessitatibus', + 'saepe', + 'eveniet', + 'ut', + 'et', + 'voluptates', + 'repudiandae', + 'sint', + 'et', + 'molestiae', + 'non', + 'recusandae', + 'Itaque', + 'earum', + 'rerum', + 'hic', + 'tenetur', + 'a', + 'sapiente', + 'delectus', + 'ut', + 'aut', + 'reiciendis', + 'voluptatibus', + 'maiores', + 'alias', + 'consequatur', + 'aut', + 'perferendis', + 'doloribus', + 'asperiores', + 'repellat', +]; diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index 2605e7796789..737db5df4a0f 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -3,95 +3,83 @@ import { DataSourceApi, DataQueryRequest, DataSourceInstanceSettings, - DataStreamObserver, + DataQueryResponse, MetricFindValue, } from '@grafana/ui'; import { TableData, TimeSeries } from '@grafana/data'; import { TestDataQuery, Scenario } from './types'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import { StreamHandler } from './StreamHandler'; import { queryMetricTree } from './metricTree'; +import { Observable, from, merge } from 'rxjs'; +import { runStream } from './runStreams'; import templateSrv from 'app/features/templating/template_srv'; type TestData = TimeSeries | TableData; -export interface TestDataRegistry { - [key: string]: TestData[]; -} - export class TestDataDataSource extends DataSourceApi { - streams = new StreamHandler(); - - /** @ngInject */ constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); } - query(options: DataQueryRequest, observer: DataStreamObserver) { - const queries = options.targets.map(item => { - return { - ...item, - intervalMs: options.intervalMs, - maxDataPoints: options.maxDataPoints, - datasourceId: this.id, - alias: templateSrv.replace(item.alias || ''), - }; - }); + query(options: DataQueryRequest): Observable { + const queries: any[] = []; + const streams: Array> = []; + + // Start streams and prepare queries + for (const target of options.targets) { + if (target.scenarioId === 'streaming_client') { + streams.push(runStream(target, options)); + } else { + queries.push({ + ...target, + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + datasourceId: this.id, + alias: templateSrv.replace(target.alias || ''), + }); + } + } - if (queries.length === 0) { - return Promise.resolve({ data: [] }); + if (queries.length) { + const req: Promise = getBackendSrv() + .datasourceRequest({ + method: 'POST', + url: '/api/tsdb/query', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: queries, + }, + // This sets up a cancel token + requestId: options.requestId, + }) + .then((res: any) => this.processQueryResult(queries, res)); + + streams.push(from(req)); } - // Currently we do not support mixed with client only streaming - const resp = this.streams.process(options, observer); - if (resp) { - return Promise.resolve(resp); + return merge(...streams); + } + + processQueryResult(queries: any, res: any): DataQueryResponse { + const data: TestData[] = []; + + for (const query of queries) { + const results = res.data.results[query.refId]; + + for (const t of results.tables || []) { + const table = t as TableData; + table.refId = query.refId; + table.name = query.alias; + data.push(table); + } + + for (const series of results.series || []) { + data.push({ target: series.name, datapoints: series.points, refId: query.refId, tags: series.tags }); + } } - return getBackendSrv() - .datasourceRequest({ - method: 'POST', - url: '/api/tsdb/query', - data: { - from: options.range.from.valueOf().toString(), - to: options.range.to.valueOf().toString(), - queries: queries, - }, - - // This sets up a cancel token - requestId: options.requestId, - }) - .then((res: any) => { - const data: TestData[] = []; - - // Returns data in the order it was asked for. - // if the response has data with different refId, it is ignored - for (const query of queries) { - const results = res.data.results[query.refId]; - if (!results) { - console.warn('No Results for:', query); - continue; - } - - for (const t of results.tables || []) { - const table = t as TableData; - table.refId = query.refId; - table.name = query.alias; - data.push(table); - } - - for (const series of results.series || []) { - data.push({ - target: series.name, - datapoints: series.points, - refId: query.refId, - tags: series.tags, - }); - } - } - - return { data: data }; - }); + return { data }; } annotationQuery(options: any) { diff --git a/public/app/plugins/datasource/testdata/query_ctrl.ts b/public/app/plugins/datasource/testdata/query_ctrl.ts index b311cf212bee..46d7003e9850 100644 --- a/public/app/plugins/datasource/testdata/query_ctrl.ts +++ b/public/app/plugins/datasource/testdata/query_ctrl.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { QueryCtrl } from 'app/plugins/sdk'; -import { defaultQuery } from './StreamHandler'; +import { defaultQuery } from './runStreams'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { dateTime } from '@grafana/data'; diff --git a/public/app/plugins/datasource/testdata/runStreams.ts b/public/app/plugins/datasource/testdata/runStreams.ts new file mode 100644 index 000000000000..12e7168c1c39 --- /dev/null +++ b/public/app/plugins/datasource/testdata/runStreams.ts @@ -0,0 +1,224 @@ +import { defaults } from 'lodash'; +import { Observable } from 'rxjs'; + +import { DataQueryRequest, DataQueryResponse } from '@grafana/ui'; + +import { FieldType, CircularDataFrame, CSVReader, Field, LoadingState } from '@grafana/data'; + +import { TestDataQuery, StreamingQuery } from './types'; +import { getRandomLine } from './LogIpsum'; + +export const defaultQuery: StreamingQuery = { + type: 'signal', + speed: 250, // ms + spread: 3.5, + noise: 2.2, + bands: 1, +}; + +export function runStream(target: TestDataQuery, req: DataQueryRequest): Observable { + const query = defaults(target.stream, defaultQuery); + if ('signal' === query.type) { + return runSignalStream(target, query, req); + } + if ('logs' === query.type) { + return runLogsStream(target, query, req); + } + if ('fetch' === query.type) { + return runFetchStream(target, query, req); + } + throw new Error(`Unknown Stream Type: ${query.type}`); +} + +export function runSignalStream( + target: TestDataQuery, + query: StreamingQuery, + req: DataQueryRequest +): Observable { + return new Observable(subscriber => { + const streamId = `signal-${req.panelId}-${target.refId}`; + const maxDataPoints = req.maxDataPoints || 1000; + + const data = new CircularDataFrame({ + append: 'tail', + capacity: maxDataPoints, + }); + data.refId = target.refId; + data.name = target.alias || 'Signal ' + target.refId; + data.addField({ name: 'time', type: FieldType.time }); + data.addField({ name: 'value', type: FieldType.number }); + + const { spread, speed, bands, noise } = query; + + for (let i = 0; i < bands; i++) { + const suffix = bands > 1 ? ` ${i + 1}` : ''; + data.addField({ name: 'Min' + suffix, type: FieldType.number }); + data.addField({ name: 'Max' + suffix, type: FieldType.number }); + } + + let value = Math.random() * 100; + let timeoutId: any = null; + + const addNextRow = (time: number) => { + value += (Math.random() - 0.5) * spread; + + let idx = 0; + data.fields[idx++].values.add(time); + data.fields[idx++].values.add(value); + + let min = value; + let max = value; + + for (let i = 0; i < bands; i++) { + min = min - Math.random() * noise; + max = max + Math.random() * noise; + + data.fields[idx++].values.add(min); + data.fields[idx++].values.add(max); + } + }; + + // Fill the buffer on init + if (true) { + let time = Date.now() - maxDataPoints * speed; + for (let i = 0; i < maxDataPoints; i++) { + addNextRow(time); + time += speed; + } + } + + const pushNextEvent = () => { + addNextRow(Date.now()); + subscriber.next({ + data: [data], + key: streamId, + }); + + timeoutId = setTimeout(pushNextEvent, speed); + }; + + // Send first event in 5ms + setTimeout(pushNextEvent, 5); + + return () => { + console.log('unsubscribing to stream ' + streamId); + clearTimeout(timeoutId); + }; + }); +} + +export function runLogsStream( + target: TestDataQuery, + query: StreamingQuery, + req: DataQueryRequest +): Observable { + return new Observable(subscriber => { + const streamId = `logs-${req.panelId}-${target.refId}`; + const maxDataPoints = req.maxDataPoints || 1000; + + const data = new CircularDataFrame({ + append: 'tail', + capacity: maxDataPoints, + }); + data.refId = target.refId; + data.name = target.alias || 'Logs ' + target.refId; + data.addField({ name: 'time', type: FieldType.time }); + data.addField({ name: 'line', type: FieldType.string }); + + const { speed } = query; + + let timeoutId: any = null; + + const pushNextEvent = () => { + data.values.time.add(Date.now()); + data.values.line.add(getRandomLine()); + + subscriber.next({ + data: [data], + key: streamId, + }); + + timeoutId = setTimeout(pushNextEvent, speed); + }; + + // Send first event in 5ms + setTimeout(pushNextEvent, 5); + + return () => { + console.log('unsubscribing to stream ' + streamId); + clearTimeout(timeoutId); + }; + }); +} + +export function runFetchStream( + target: TestDataQuery, + query: StreamingQuery, + req: DataQueryRequest +): Observable { + return new Observable(subscriber => { + const streamId = `fetch-${req.panelId}-${target.refId}`; + const maxDataPoints = req.maxDataPoints || 1000; + + let data = new CircularDataFrame({ + append: 'tail', + capacity: maxDataPoints, + }); + data.refId = target.refId; + data.name = target.alias || 'Fetch ' + target.refId; + + let reader: ReadableStreamReader; + const csv = new CSVReader({ + callback: { + onHeader: (fields: Field[]) => { + // Clear any existing fields + if (data.fields.length) { + data = new CircularDataFrame({ + append: 'tail', + capacity: maxDataPoints, + }); + data.refId = target.refId; + data.name = 'Fetch ' + target.refId; + } + for (const field of fields) { + data.addField(field); + } + }, + onRow: (row: any[]) => { + data.add(row); + }, + }, + }); + + const processChunk = (value: ReadableStreamReadResult): any => { + if (value.value) { + const text = new TextDecoder().decode(value.value); + csv.readCSV(text); + } + + subscriber.next({ + data: [data], + key: streamId, + state: value.done ? LoadingState.Done : LoadingState.Streaming, + }); + + if (value.done) { + console.log('Finished stream'); + subscriber.complete(); // necessary? + return; + } + + return reader.read().then(processChunk); + }; + + fetch(new Request(query.url)).then(response => { + reader = response.body.getReader(); + reader.read().then(processChunk); + }); + + return () => { + // Cancel fetch? + console.log('unsubscribing to stream ' + streamId); + }; + }); +} diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index c7bdfd990317..56b9bab8ec52 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -13,8 +13,7 @@ import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import { DataFrame, DataLink, DateTimeInput } from '@grafana/data'; import { getColorFromHexRgbOrName, LegacyResponseData, VariableSuggestion } from '@grafana/ui'; -import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState'; -import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner'; +import { getProcessedDataFrames } from 'app/features/dashboard/state/runRequest'; import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; @@ -143,7 +142,7 @@ class GraphCtrl extends MetricsPanelCtrl { _.defaults(this.panel.xaxis, this.panelDefaults.xaxis); _.defaults(this.panel.options, this.panelDefaults.options); - this.dataFormat = PanelQueryRunnerFormat.frames; + this.useDataFrames = true; this.processor = new DataProcessor(this.panel); this.contextMenuCtrl = new GraphContextMenuCtrl($scope); diff --git a/public/app/plugins/panel/graph/specs/data_processor.test.ts b/public/app/plugins/panel/graph/specs/data_processor.test.ts index da95160b066a..8b76536b39ca 100644 --- a/public/app/plugins/panel/graph/specs/data_processor.test.ts +++ b/public/app/plugins/panel/graph/specs/data_processor.test.ts @@ -1,5 +1,5 @@ import { DataProcessor } from '../data_processor'; -import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState'; +import { getProcessedDataFrames } from 'app/features/dashboard/state/runRequest'; describe('Graph DataProcessor', () => { const panel: any = { diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 7ff672a67d54..d73cb9b5bb0b 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -28,8 +28,7 @@ import { } from '@grafana/data'; import { auto } from 'angular'; import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; -import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner'; -import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState'; +import { getProcessedDataFrames } from 'app/features/dashboard/state/runRequest'; const BASE_FONT_SIZE = 38; @@ -124,7 +123,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { this.events.on('data-snapshot-load', this.onDataReceived.bind(this)); this.events.on('init-edit-mode', this.onInitEditMode.bind(this)); - this.dataFormat = PanelQueryRunnerFormat.frames; + this.useDataFrames = true; this.onSparklineColorChange = this.onSparklineColorChange.bind(this); this.onSparklineFillChange = this.onSparklineFillChange.bind(this); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 7ddc236de34b..37427dd69049 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,3 +1,4 @@ +import { Unsubscribable } from 'rxjs'; import { ComponentClass } from 'react'; import { DataQuery, @@ -21,7 +22,6 @@ import { import { Emitter } from 'app/core/core'; import TableModel from 'app/core/table_model'; -import { PanelQueryState } from '../features/dashboard/state/PanelQueryState'; export enum ExploreMode { Metrics = 'Metrics', @@ -263,7 +263,7 @@ export interface ExploreItemState { isPaused: boolean; urlReplaced: boolean; - queryState: PanelQueryState; + querySubscription?: Unsubscribable; queryResponse: PanelData; originPanelId?: number; diff --git a/public/test/core/redux/reducerTester.ts b/public/test/core/redux/reducerTester.ts index db645e074c36..ffcbbf20ee32 100644 --- a/public/test/core/redux/reducerTester.ts +++ b/public/test/core/redux/reducerTester.ts @@ -11,7 +11,7 @@ export interface When { } export interface Then { - thenStateShouldEqual: (state: State) => Then; + thenStateShouldEqual: (state: State) => When; } interface ObjectType extends Object { @@ -62,12 +62,12 @@ export const reducerTester = (): Given => { }; const whenActionIsDispatched = (action: ActionOf): Then => { - resultingState = reducerUnderTest(initialState, action); + resultingState = reducerUnderTest(resultingState || initialState, action); return instance; }; - const thenStateShouldEqual = (state: State): Then => { + const thenStateShouldEqual = (state: State): When => { expect(state).toEqual(resultingState); return instance; From 8c5d925063c8b43f7c7992c2c34ef0fc3d791030 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 12 Sep 2019 08:39:11 -0700 Subject: [PATCH 26/87] toolkit: pipe execa output to console.stdout (#19052) --- packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index f8388df63816..cf33a5ad7f5b 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -64,8 +64,7 @@ const buildPluginRunner: TaskRunner = async ({ backend }) => { } // Run plugin-ci task - const exe = await execa('make', ['backend-plugin-ci']); - console.log(exe.stdout); + execa('make', ['backend-plugin-ci']).stdout.pipe(process.stdout); } else { // Do regular build process with coverage await pluginBuildRunner({ coverage: true }); From 3f6a37f9af4041b962b35619878b72d98271eb35 Mon Sep 17 00:00:00 2001 From: oddlittlebird <52059945+oddlittlebird@users.noreply.github.com> Date: Thu, 12 Sep 2019 09:34:54 -0700 Subject: [PATCH 27/87] Update README.md (#19047) Added punctuation and made minor grammar and format edits. Mostly added periods and colons. --- README.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8d2ddecfa9ee..f7c8ae504ce0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Be sure to read the [getting started guide](http://docs.grafana.org/guides/getti ## Run from master If you want to build a package yourself, or contribute - here is a guide for how to do that. You can always find -the latest master builds [here](https://grafana.com/grafana/download) +the latest master builds [here](https://grafana.com/grafana/download). ### Dependencies @@ -105,18 +105,18 @@ Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafan #### Building on linux/amd64 (fast) -1. Build the frontend `go run build.go build-frontend` -2. Build the docker image `make build-docker-dev` +1. Build the frontend `go run build.go build-frontend`. +2. Build the docker image `make build-docker-dev`. -The resulting image will be tagged as `grafana/grafana:dev` +The resulting image will be tagged as `grafana/grafana:dev`. #### Building anywhere (slower) Choose this option to build on platforms other than linux/amd64 and/or not have to setup the Grafana development environment. -1. `make build-docker-full` or `docker build -t grafana/grafana:dev .` +1. `make build-docker-full` or `docker build -t grafana/grafana:dev`. -The resulting image will be tagged as `grafana/grafana:dev` +The resulting image will be tagged as `grafana/grafana:dev`. Notice: If you are using Docker for MacOS, be sure to set the memory limit to be larger than 2 GiB (at docker -> Preferences -> Advanced), otherwise `grunt build` may fail. @@ -136,16 +136,16 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = #### Frontend -Execute all frontend tests +Execute all frontend tests: ```bash yarn test ``` -Writing & watching frontend tests +Write and watch frontend tests: -- Start watcher: `yarn jest` -- Jest will run all test files that end with the name ".test.ts" +- Start watcher: `yarn jest`. +- Jest runs all test files that end with the name ".test.ts". #### Backend @@ -162,7 +162,7 @@ Run these by setting `GRAFANA_TEST_DB` in your environment. - `GRAFANA_TEST_DB=postgres` to test Postgres Follow the instructions in `./devenv` to spin up test containers running the appropriate databases with `docker-compose` -- Use `docker/blocks/mysql_tests` or `docker/blocks/postgres_tests` as appropriate +- Use `docker/blocks/mysql_tests` or `docker/blocks/postgres_tests` as appropriate. ```bash # MySQL @@ -184,19 +184,19 @@ GRAFANA_TEST_DB=postgres go test ./pkg/services/sqlstore/... #### End-to-end -Execute all end-to-end tests +Execute all end-to-end tests: ```bash yarn e2e-tests ``` -Execute all end-to-end tests using using a specific url +Execute all end-to-end tests using using a specific url: ```bash ENV BASE_URL=http://localhost:3333 yarn e2e-tests ``` -Debugging all end-to-end tests (BROWSER=1 will start the browser and SLOWMO=1 will delay each puppeteer operation by 100ms) +Debugging all end-to-end tests (BROWSER=1 starts the browser and SLOWMO=1 delays each puppeteer operation by 100ms): ```bash ENV BROWSER=1 SLOWMO=1 yarn e2e-tests @@ -210,15 +210,13 @@ that will populate your dev environment for quicker testing and experimenting. ## Contribute If you have any ideas for improvement or have found a bug, do not hesitate to open an issue. -And if you have time, clone this repo and submit a pull request to help me make Grafana -the kickass metrics & devops dashboard we all dream about! +And if you have time, clone this repo and submit a pull request to help me make Grafana the kickass metrics and devops dashboard we all dream about! Read the [contributing](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guide then check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are easy and that we would like help with. ## Plugin development -Checkout the [Plugin Development Guide](http://docs.grafana.org/plugins/developing/development/) and checkout the [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) file for changes in Grafana that relate to -plugin development. +Checkout the [Plugin Development Guide](http://docs.grafana.org/plugins/developing/development/) and checkout the [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) file for changes in Grafana that relate to plugin development. ## License From 81c326b31a39a50a7d356f8ab1d1084bdc4d1993 Mon Sep 17 00:00:00 2001 From: oddlittlebird <52059945+oddlittlebird@users.noreply.github.com> Date: Thu, 12 Sep 2019 09:36:46 -0700 Subject: [PATCH 28/87] Update CONTRIBUTING.md (#19051) Minor grammar edits. Mostly added periods and colons. Made heading capitalization consistent. --- CONTRIBUTING.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 174f53140c3b..1bf86b675e19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ Grafana uses GitHub to manage contributions. Contributions take the form of pull requests that will be reviewed by the core team. -- If you are a new contributor see: [Steps to Contribute](#steps-to-contribute) +- If you are a new contributor see: [Steps to Contribute](#steps-to-contribute). - If you have a trivial fix or improvement, go ahead and create a pull request. @@ -11,19 +11,19 @@ Contributions take the form of pull requests that will be reviewed by the core t - Sign our [CLA](http://docs.grafana.org/contribute/cla/). -- Make sure to follow the code style guides +- Make sure to follow the code style guides: - [Backend](https://github.com/grafana/grafana/tree/master/pkg) - [Frontend](https://github.com/grafana/grafana/tree/master/style_guides) -## Steps to Contribute +## Steps to contribute Should you wish to work on a GitHub issue, check first if it is not already assigned to someone. If it is free, you claim it by commenting on the issue that you want to work on it. This is to prevent duplicated efforts from contributors on the same issue. Please check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) and [`help wanted`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) labels to find issues that are good for getting started. If you have questions about one of the issues, with or without the tag, please comment on them and one of the core team or the original poster will clarify it. -To setup a local development environment we recommend reading [Building Grafana from source](http://docs.grafana.org/project/building_from_source/) +To setup a local development environment we recommend reading [Building Grafana from source](http://docs.grafana.org/project/building_from_source/). -## Pull Request Checklist +## Pull request checklist Whether you are contributing or doing code review, first read and understand https://google.github.io/eng-practices/review/reviewer/ for general engineering practices around code reviews that we also use. @@ -33,26 +33,26 @@ Whether you are contributing or doing code review, first read and understand htt - Add tests relevant to the fixed bug or new feature. -### High level checks +### High-level checks - [ ] The pull request adds value and the impact of the change is in line with [Backend](https://github.com/grafana/grafana/tree/master/pkg) or [Frontend](https://github.com/grafana/grafana/tree/master/style_guides). - [ ] The pull request works the way it says it should do. - [ ] The pull request closes one issue if possible and does not fix unrelated issues within the same pull request. - [ ] The pull request contains necessary tests. -### Low level checks +### Low-level checks -- [ ] The pull request contains a title that explains it. It follows [PR and commit messages guidelines](#Pull-Requests-titles-and-message) -- [ ] The pull request contains necessary link(s) to issue(s). -- [ ] The pull request contains commits with messages that are small and understandable. It follows [PR and commit messages guidelines](#Pull-Requests-titles-and-message) +- [ ] The pull request contains a title that explains it. It follows [PR and commit messages guidelines](#Pull-Requests-titles-and-message). +- [ ] The pull request contains necessary links to issues. +- [ ] The pull request contains commits with messages that are small and understandable. It follows [PR and commit messages guidelines](#Pull-Requests-titles-and-message). - [ ] The pull request does not contain magic strings or numbers that could be replaced with an `Enum` or `const` instead. -#### Bug specific checks +#### Bug-specific checks - [ ] The pull request contains `Closes: #Issue` or `Fixes: #Issue` in pull request description. - [ ] The Pull Request adds tests that replicate the fixed bug and helps avoid regressions. -### Frontend specific checks +### Frontend-specific checks - [ ] The pull request does not increase the Angular code base. > We are in the process of migrating to React so any increment of Angular code is generally discouraged. @@ -60,19 +60,19 @@ Whether you are contributing or doing code review, first read and understand htt - [ ] The pull request does not contain large React components that could easily be split into several smaller components. - [ ] The pull request does not contain back end calls directly from components, use actions and Redux instead. -#### Redux specific checks (skip if pull request does not contain Redux changes) +#### Redux specific checks (skip if your pull request does not contain Redux changes) - [ ] The pull request does not contain code that mutates state in reducers or thunks. - [ ] The pull request uses helpers `actionCreatorFactory` and `reducerFactory` instead of traditional `switch statement` reducers in Redux. See [Redux framework](https://github.com/grafana/grafana/tree/master/style_guides/redux.md) for more details. - [ ] The pull request uses `reducerTester` to test reducers. See [Redux framework](https://github.com/grafana/grafana/tree/master/style_guides/redux.md) for more details. - [ ] The pull request does not contain code that accesses the reducers state slice directly, instead, the code uses state selectors to access state. -### Pull Requests titles and message +### Pull request titles and message Pull request titles should follow this format: `Area: Name of the change`. Titles are used to generate the changelog so they should be as descriptive as possible in one line. -Good Examples +Good examples: - `Explore: Adds Live option for supported datasources` - `GraphPanel: Don't sort series when legend table & sort column is not visible` @@ -87,7 +87,7 @@ The commit message of the commits in the Pull Request can still be part of the g The Git commit title should be short, descriptive and include the Pull Request ID. -Good Examples +Good examples: - `Explore: Live supprt in datasources (#12345)` - `GraphPanel: Fix legend sorting issues (#12345)` From 9eafc693b2a4266636bf03265e0f1a0b88af1d32 Mon Sep 17 00:00:00 2001 From: Brian Gann Date: Thu, 12 Sep 2019 18:44:31 +0200 Subject: [PATCH 29/87] MSSQL: Fix memory leak when debug enabled (#19049) * fix for #19049 --- pkg/tsdb/mssql/mssql.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 82e15727e2fc..692142246dad 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -68,8 +68,9 @@ func (t *mssqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *cor values := make([]interface{}, len(columnTypes)) valuePtrs := make([]interface{}, len(columnTypes)) - for i, stype := range columnTypes { - t.log.Debug("type", "type", stype) + for i := range columnTypes { + // debug output on large tables causes high memory utilization/leak + // t.log.Debug("type", "type", stype) valuePtrs[i] = &values[i] } From 09b434bdd08a65714e7fd09c802a0835c3261132 Mon Sep 17 00:00:00 2001 From: 548017 <1553762+548017@users.noreply.github.com> Date: Thu, 12 Sep 2019 18:45:50 +0200 Subject: [PATCH 30/87] Auth: Allow inviting existing users when login form is disabled (#19048) Allow api to accept inviting existing users when login form is disabled. UI shows invite button when login form is disabled. --- pkg/api/org_invite.go | 8 ++++---- public/app/features/users/state/reducers.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index a0f84ae0c854..7d928501d71c 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -27,10 +27,6 @@ func GetPendingOrgInvites(c *m.ReqContext) Response { } func AddOrgInvite(c *m.ReqContext, inviteDto dtos.AddInviteForm) Response { - if setting.DisableLoginForm { - return Error(400, "Cannot invite when login is disabled.", nil) - } - if !inviteDto.Role.IsValid() { return Error(400, "Invalid role specified", nil) } @@ -45,6 +41,10 @@ func AddOrgInvite(c *m.ReqContext, inviteDto dtos.AddInviteForm) Response { return inviteExistingUserToOrg(c, userQuery.Result, &inviteDto) } + if setting.DisableLoginForm { + return Error(400, "Cannot invite when login is disabled.", nil) + } + cmd := m.CreateTempUserCommand{} cmd.OrgId = c.OrgId cmd.Email = inviteDto.LoginOrEmail diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts index d31682e0c45c..e9896a28d110 100644 --- a/public/app/features/users/state/reducers.ts +++ b/public/app/features/users/state/reducers.ts @@ -6,7 +6,7 @@ export const initialState: UsersState = { invitees: [] as Invitee[], users: [] as OrgUser[], searchQuery: '', - canInvite: !config.disableLoginForm && !config.externalUserMngLinkName, + canInvite: !config.externalUserMngLinkName, externalUserMngInfo: config.externalUserMngInfo, externalUserMngLinkName: config.externalUserMngLinkName, externalUserMngLinkUrl: config.externalUserMngLinkUrl, From e3a99a0a3dd20893b309577f445575642190d92b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 12 Sep 2019 10:32:12 -0700 Subject: [PATCH 31/87] toolkit: fix master build, avoid null check (#19055) --- docs/sources/guides/whats-new-in-v4-5.md | 2 +- packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/guides/whats-new-in-v4-5.md b/docs/sources/guides/whats-new-in-v4-5.md index c6cfcf64720d..1df1609b7110 100644 --- a/docs/sources/guides/whats-new-in-v4-5.md +++ b/docs/sources/guides/whats-new-in-v4-5.md @@ -56,7 +56,7 @@ More information [here](https://community.grafana.com/t/using-grafanas-query-ins This option is now renamed (and moved to Options sub section above your queries): ![image|519x120](upload://ySjHOVpavV6yk9LHQxL9nq2HIsT.png) -Datas source selection & options & help are now above your metric queries. +Data source selection & options & help are now above your metric queries. ![image|690x179](upload://5kNDxKgMz1BycOKgG3iWYLsEVXv.png) ### Minor Changes diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index cf33a5ad7f5b..0a3d9fd6447d 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -64,7 +64,7 @@ const buildPluginRunner: TaskRunner = async ({ backend }) => { } // Run plugin-ci task - execa('make', ['backend-plugin-ci']).stdout.pipe(process.stdout); + execa('make', ['backend-plugin-ci']).stdout!.pipe(process.stdout); } else { // Do regular build process with coverage await pluginBuildRunner({ coverage: true }); From 55717769a31c5380c514dd18716e394d85bd0a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 12 Sep 2019 21:42:50 +0200 Subject: [PATCH 32/87] QueryProcessing: Fixes showing last result in initial loading state (#19057) * PanelQueryRunner: Need to cache preProcessPanelData function between runs so last result can be remembered * Better fix for remembering lastResult * Code simplification * Simplify code a bit --- .../dashboard/state/PanelQueryRunner.ts | 7 +++-- .../features/dashboard/state/runRequest.ts | 27 +++++++------------ public/app/features/explore/state/actions.ts | 7 ++++- public/app/features/explore/state/reducers.ts | 4 +-- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/public/app/features/dashboard/state/PanelQueryRunner.ts b/public/app/features/dashboard/state/PanelQueryRunner.ts index a8a5cf1f411d..85e9c7eca2d4 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.ts @@ -44,6 +44,7 @@ export class PanelQueryRunner { private subject?: ReplaySubject; private subscription?: Unsubscribable; private transformations?: DataTransformerConfig[]; + private lastResult?: PanelData; constructor() { this.subject = new ReplaySubject(1); @@ -153,12 +154,10 @@ export class PanelQueryRunner { this.subscription.unsubscribe(); } - // Makes sure everything is a proper DataFrame - const prepare = preProcessPanelData(); - this.subscription = observable.subscribe({ next: (data: PanelData) => { - this.subject.next(prepare(data)); + this.lastResult = preProcessPanelData(data, this.lastResult); + this.subject.next(this.lastResult); }, }); } diff --git a/public/app/features/dashboard/state/runRequest.ts b/public/app/features/dashboard/state/runRequest.ts index 9a41a060ec93..4575724f5fde 100644 --- a/public/app/features/dashboard/state/runRequest.ts +++ b/public/app/features/dashboard/state/runRequest.ts @@ -187,25 +187,18 @@ export function getProcessedDataFrames(results?: DataQueryResponseData[]): DataF return dataFrames; } -export function preProcessPanelData() { - let lastResult: PanelData = null; +export function preProcessPanelData(data: PanelData, lastResult: PanelData) { + let { series } = data; - return function mapper(data: PanelData) { - let { series } = data; - - // for loading states with no data, use last result - if (data.state === LoadingState.Loading && series.length === 0) { - if (!lastResult) { - lastResult = data; - } - - return { ...lastResult, state: LoadingState.Loading }; + // for loading states with no data, use last result + if (data.state === LoadingState.Loading && series.length === 0) { + if (!lastResult) { + lastResult = data; } - // Makes sure the data is properly formatted - series = getProcessedDataFrames(series); + return { ...lastResult, state: LoadingState.Loading }; + } - lastResult = { ...data, series }; - return lastResult; - }; + // Makes sure the data is properly formatted + return getProcessedDataFrames(series); } diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index f5fb5091082c..c09d0f8c2c1a 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -439,6 +439,7 @@ export function runQueries(exploreId: ExploreId): ThunkResult { queryIntervals, range, scanning, + queryResponse, querySubscription, history, mode, @@ -479,7 +480,11 @@ export function runQueries(exploreId: ExploreId): ThunkResult { let firstResponse = true; const newQuerySub = runRequest(datasourceInstance, transaction.request) - .pipe(map(preProcessPanelData())) + .pipe( + map((data: PanelData) => { + return preProcessPanelData(data, queryResponse); + }) + ) .subscribe((data: PanelData) => { if (!data.error && firstResponse) { // Side-effect: Saving history in localstorage diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 1d8b0d2471f3..8b57d63ba9bf 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -618,8 +618,8 @@ export const processQueryResponse = ( const latency = request.endTime ? request.endTime - request.startTime : 0; const processor = new ResultProcessor(state, series); - const graphResult = processor.getGraphResult() || state.graphResult; // don't replace results until we receive new results - const tableResult = processor.getTableResult() || state.tableResult || new TableModel(); // don't replace results until we receive new results + const graphResult = processor.getGraphResult(); + const tableResult = processor.getTableResult(); const logsResult = processor.getLogsResult(); // Send legacy data to Angular editors From 5b9901ebba959bff99cf2d4c0011a8c7865ded05 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 12 Sep 2019 21:40:10 -0700 Subject: [PATCH 33/87] GraphPanel: don't listen to legacy onDataReceived events (#19054) * don't listen to legacy data events in graph * fix test * rename function * add annotationsSrv stub * use const * fix preProcessPanelData * update test --- public/app/features/dashboard/state/runRequest.ts | 11 +++++++---- public/app/plugins/panel/graph/module.ts | 13 ++++--------- public/app/plugins/panel/graph/specs/graph.test.ts | 3 +++ .../plugins/panel/graph/specs/graph_ctrl.test.ts | 9 ++++++--- public/app/plugins/panel/singlestat/module.ts | 5 ++--- .../panel/singlestat/specs/singlestat.test.ts | 2 +- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/public/app/features/dashboard/state/runRequest.ts b/public/app/features/dashboard/state/runRequest.ts index 4575724f5fde..4f12d470d230 100644 --- a/public/app/features/dashboard/state/runRequest.ts +++ b/public/app/features/dashboard/state/runRequest.ts @@ -187,8 +187,8 @@ export function getProcessedDataFrames(results?: DataQueryResponseData[]): DataF return dataFrames; } -export function preProcessPanelData(data: PanelData, lastResult: PanelData) { - let { series } = data; +export function preProcessPanelData(data: PanelData, lastResult: PanelData): PanelData { + const { series } = data; // for loading states with no data, use last result if (data.state === LoadingState.Loading && series.length === 0) { @@ -199,6 +199,9 @@ export function preProcessPanelData(data: PanelData, lastResult: PanelData) { return { ...lastResult, state: LoadingState.Loading }; } - // Makes sure the data is properly formatted - return getProcessedDataFrames(series); + // Make sure the data frames are properly formatted + return { + ...data, + series: getProcessedDataFrames(series), + }; } diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 56b9bab8ec52..3e1bbc6dc12e 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -12,7 +12,7 @@ import { axesEditorComponent } from './axes_editor'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import { DataFrame, DataLink, DateTimeInput } from '@grafana/data'; -import { getColorFromHexRgbOrName, LegacyResponseData, VariableSuggestion } from '@grafana/ui'; +import { getColorFromHexRgbOrName, VariableSuggestion } from '@grafana/ui'; import { getProcessedDataFrames } from 'app/features/dashboard/state/runRequest'; import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; @@ -147,7 +147,6 @@ class GraphCtrl extends MetricsPanelCtrl { this.contextMenuCtrl = new GraphContextMenuCtrl($scope); this.events.on('render', this.onRender.bind(this)); - this.events.on('data-received', this.onDataReceived.bind(this)); this.events.on('data-frames-received', this.onDataFramesReceived.bind(this)); this.events.on('data-error', this.onDataError.bind(this)); this.events.on('data-snapshot-load', this.onDataSnapshotLoad.bind(this)); @@ -199,7 +198,9 @@ class GraphCtrl extends MetricsPanelCtrl { panel: this.panel, range: this.range, }); - this.onDataReceived(snapshotData); + + const frames = getProcessedDataFrames(snapshotData); + this.onDataFramesReceived(frames); } onDataError(err: any) { @@ -208,12 +209,6 @@ class GraphCtrl extends MetricsPanelCtrl { this.render([]); } - // This should only be called from the snapshot callback - onDataReceived(dataList: LegacyResponseData[]) { - this.onDataFramesReceived(getProcessedDataFrames(dataList)); - } - - // Directly support DataFrame skipping event callbacks onDataFramesReceived(data: DataFrame[]) { this.dataList = data; this.seriesList = this.processor.getSeriesList({ diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index cf13eefe8855..555fbdf88532 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -88,6 +88,9 @@ describe('grafanaGraph', () => { from: dateTime([2015, 1, 1, 10]), to: dateTime([2015, 1, 1, 22]), }, + annotationsSrv: { + getAnnotations: () => Promise.resolve({}), + }, } as any; ctx.data = []; diff --git a/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts b/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts index 3c59baf6433f..4d030800d1c2 100644 --- a/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts +++ b/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts @@ -37,6 +37,9 @@ describe('GraphCtrl', () => { ctx.ctrl.events = { emit: () => {}, }; + ctx.ctrl.annotationsSrv = { + getAnnotations: () => Promise.resolve({}), + }; ctx.ctrl.annotationsPromise = Promise.resolve({}); ctx.ctrl.updateTimeRange(); }); @@ -51,7 +54,7 @@ describe('GraphCtrl', () => { ]; ctx.ctrl.range = { from: dateTime().valueOf(), to: dateTime().valueOf() }; - ctx.ctrl.onDataReceived(data); + ctx.ctrl.onDataSnapshotLoad(data); }); it('should set datapointsOutside', () => { @@ -76,7 +79,7 @@ describe('GraphCtrl', () => { ]; ctx.ctrl.range = range; - ctx.ctrl.onDataReceived(data); + ctx.ctrl.onDataSnapshotLoad(data); }); it('should set datapointsOutside', () => { @@ -87,7 +90,7 @@ describe('GraphCtrl', () => { describe('datapointsCount given 2 series', () => { beforeEach(() => { const data: any = [{ target: 'test.cpu1', datapoints: [] }, { target: 'test.cpu2', datapoints: [] }]; - ctx.ctrl.onDataReceived(data); + ctx.ctrl.onDataSnapshotLoad(data); }); it('should set datapointsCount warning', () => { diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index d73cb9b5bb0b..8e93c38649a5 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -120,7 +120,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { this.events.on('data-frames-received', this.onFramesReceived.bind(this)); this.events.on('data-error', this.onDataError.bind(this)); - this.events.on('data-snapshot-load', this.onDataReceived.bind(this)); + this.events.on('data-snapshot-load', this.onSnapshotLoad.bind(this)); this.events.on('init-edit-mode', this.onInitEditMode.bind(this)); this.useDataFrames = true; @@ -154,8 +154,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { this.handleDataFrames([]); } - // This should only be called from the snapshot callback - onDataReceived(dataList: LegacyResponseData[]) { + onSnapshotLoad(dataList: LegacyResponseData[]) { this.onFramesReceived(getProcessedDataFrames(dataList)); } diff --git a/public/app/plugins/panel/singlestat/specs/singlestat.test.ts b/public/app/plugins/panel/singlestat/specs/singlestat.test.ts index 0fd4454fd376..67d543f90a4a 100644 --- a/public/app/plugins/panel/singlestat/specs/singlestat.test.ts +++ b/public/app/plugins/panel/singlestat/specs/singlestat.test.ts @@ -45,7 +45,7 @@ describe('SingleStatCtrl', () => { // @ts-ignore ctx.ctrl = new SingleStatCtrl($scope, $injector, {} as LinkSrv, $sanitize); setupFunc(); - ctx.ctrl.onDataReceived(ctx.input); + ctx.ctrl.onSnapshotLoad(ctx.input); ctx.data = ctx.ctrl.data; }); }; From 076a8fc85c4452cff8a81f76a668ade8553f37f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 13 Sep 2019 08:13:01 +0200 Subject: [PATCH 34/87] Explore: Fixes issue with lastResult being null (#19081) --- public/app/features/explore/state/reducers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 8b57d63ba9bf..2874b97b892f 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -11,7 +11,7 @@ import { } from 'app/core/utils/explore'; import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore'; import { LoadingState, toLegacyResponseData } from '@grafana/data'; -import { DataQuery, DataSourceApi, PanelData } from '@grafana/ui'; +import { DataQuery, DataSourceApi, PanelData, DataQueryRequest } from '@grafana/ui'; import { HigherOrderAction, ActionTypes, @@ -122,7 +122,7 @@ export const makeExploreItemState = (): ExploreItemState => ({ export const createEmptyQueryResponse = (): PanelData => ({ state: LoadingState.NotStarted, - request: null, + request: {} as DataQueryRequest, series: [], error: null, }); From b6c501bc8a2e535a83d8beb757a46d1d488a7a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 13 Sep 2019 10:09:25 +0200 Subject: [PATCH 35/87] QueryProcessing: Added missing error event for angular editors (#19059) * QueryProcessing: Added missing error event for angular editors * fix if else --- .../features/dashboard/panel_editor/QueryEditorRow.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx index 4f5579b754ba..31b0377a0323 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx +++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx @@ -274,8 +274,12 @@ function notifyAngularQueryEditorsOfData(panel: PanelModel, data: PanelData, edi globalLastPanelDataCache = data; - const legacy = data.series.map(v => toLegacyResponseData(v)); - panel.events.emit('data-received', legacy); + if (data.state === LoadingState.Done) { + const legacy = data.series.map(v => toLegacyResponseData(v)); + panel.events.emit('data-received', legacy); + } else if (data.state === LoadingState.Error) { + panel.events.emit('data-error', data.error); + } // Some query controllers listen to data error events and need a digest // for some reason this needs to be done in next tick From 4c8f417f1859578127be6f931c5393278ae55ee7 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 13 Sep 2019 10:45:59 +0200 Subject: [PATCH 36/87] Stackdriver: Add extra alignment period options (#18909) * Add more alignment period options * Remove unnecessary alignment period limit --- pkg/tsdb/stackdriver/stackdriver.go | 6 ------ pkg/tsdb/stackdriver/stackdriver_test.go | 11 ----------- .../app/plugins/datasource/stackdriver/constants.ts | 4 ++++ 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go index e811e70c775d..0c65738fac3f 100644 --- a/pkg/tsdb/stackdriver/stackdriver.go +++ b/pkg/tsdb/stackdriver/stackdriver.go @@ -261,12 +261,6 @@ func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) { } } - re := regexp.MustCompile("[0-9]+") - seconds, err := strconv.ParseInt(re.FindString(alignmentPeriod), 10, 64) - if err != nil || seconds > 3600 { - alignmentPeriod = "+3600s" - } - params.Add("aggregation.crossSeriesReducer", crossSeriesReducer) params.Add("aggregation.perSeriesAligner", perSeriesAligner) params.Add("aggregation.alignmentPeriod", alignmentPeriod) diff --git a/pkg/tsdb/stackdriver/stackdriver_test.go b/pkg/tsdb/stackdriver/stackdriver_test.go index 1ab1e099ff7e..9d72162cf861 100644 --- a/pkg/tsdb/stackdriver/stackdriver_test.go +++ b/pkg/tsdb/stackdriver/stackdriver_test.go @@ -147,17 +147,6 @@ func TestStackdriver(t *testing.T) { }) Convey("and alignmentPeriod is set in frontend", func() { - Convey("and alignment period is too big", func() { - tsdbQuery.Queries[0].IntervalMs = 1000 - tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{ - "alignmentPeriod": "+360000s", - }) - - queries, err := executor.buildQueries(tsdbQuery) - So(err, ShouldBeNil) - So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+3600s`) - }) - Convey("and alignment period is within accepted range", func() { tsdbQuery.Queries[0].IntervalMs = 1000 tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{ diff --git a/public/app/plugins/datasource/stackdriver/constants.ts b/public/app/plugins/datasource/stackdriver/constants.ts index 45afa0a76842..ca591f4aef6b 100644 --- a/public/app/plugins/datasource/stackdriver/constants.ts +++ b/public/app/plugins/datasource/stackdriver/constants.ts @@ -233,11 +233,15 @@ export const alignmentPeriods = [ { text: 'grafana auto', value: 'grafana-auto' }, { text: 'stackdriver auto', value: 'stackdriver-auto' }, { text: '1m', value: '+60s' }, + { text: '2m', value: '+120s' }, { text: '5m', value: '+300s' }, + { text: '10m', value: '+600s' }, { text: '30m', value: '+1800s' }, { text: '1h', value: '+3600s' }, + { text: '3h', value: '+7200s' }, { text: '6h', value: '+21600s' }, { text: '1d', value: '+86400s' }, + { text: '3d', value: '+259200s' }, { text: '1w', value: '+604800s' }, ]; From 81ec76bdef03e3706bd7502703a58f6195c5e70f Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 13 Sep 2019 10:58:29 +0200 Subject: [PATCH 37/87] Explore: Add throttling when doing live queries (#19085) --- public/app/features/explore/state/actions.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index c09d0f8c2c1a..b6de5dbd0e54 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -1,5 +1,6 @@ // Libraries -import { map } from 'rxjs/operators'; +import { map, throttleTime } from 'rxjs/operators'; +import { identity } from 'rxjs'; // Services & Utils import store from 'app/core/store'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -481,9 +482,11 @@ export function runQueries(exploreId: ExploreId): ThunkResult { const newQuerySub = runRequest(datasourceInstance, transaction.request) .pipe( - map((data: PanelData) => { - return preProcessPanelData(data, queryResponse); - }) + map((data: PanelData) => preProcessPanelData(data, queryResponse)), + // Simple throttle for live tailing, in case of > 1000 rows per interval we spend about 200ms on processing and + // rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user + // actually can see what is happening. + live ? throttleTime(500) : identity ) .subscribe((data: PanelData) => { if (!data.error && firstResponse) { From c3e846c95f4bda804b1163bac78ca979d653bc0b Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri, 13 Sep 2019 13:58:29 +0200 Subject: [PATCH 38/87] Explore: Fix how log bars in graph are stacking (#19015) --- public/app/core/logs_model.ts | 9 +- public/app/core/specs/logs_model.test.ts | 106 +++++++++++++++++++++-- public/app/core/utils/explore.ts | 2 +- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 763d175548fc..526da09a3eb5 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -24,6 +24,7 @@ import { } from '@grafana/data'; import { getThemeColor } from 'app/core/utils/colors'; import { hasAnsiCodes } from 'app/core/utils/text'; +import { sortInAscendingOrder } from 'app/core/utils/explore'; import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel'; export const LogLevelColor = { @@ -106,7 +107,8 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap const bucketSize = intervalMs * 10; const seriesList: any[] = []; - for (const row of rows) { + const sortedRows = rows.sort(sortInAscendingOrder); + for (const row of sortedRows) { let series = seriesByLevel[row.logLevel]; if (!series) { @@ -120,8 +122,9 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Grap seriesList.push(series); } - // align time to bucket size - const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize; + // align time to bucket size - used Math.floor for calculation as time of the bucket + // must be in the past (before Date.now()) to be displayed on the graph + const time = Math.floor(row.timeEpochMs / bucketSize) * bucketSize; // Entry for time if (time === series.lastTs) { diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index aa687d05ddf9..1caeba49291b 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/specs/logs_model.test.ts @@ -291,7 +291,7 @@ describe('dataFrameToLogsModel', () => { ]); }); - it('given multiple series should return expected logs model', () => { + it('given multiple series with unique times should return expected logs model', () => { const series: DataFrame[] = [ toDataFrame({ labels: { @@ -337,18 +337,18 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.hasUniqueLabels).toBeTruthy(); expect(logsModel.rows).toHaveLength(3); expect(logsModel.rows).toMatchObject([ - { - entry: 'WARN boooo', - labels: { foo: 'bar', baz: '1' }, - logLevel: LogLevel.debug, - uniqueLabels: { baz: '1' }, - }, { entry: 'INFO 1', labels: { foo: 'bar', baz: '2' }, logLevel: LogLevel.error, uniqueLabels: { baz: '2' }, }, + { + entry: 'WARN boooo', + labels: { foo: 'bar', baz: '1' }, + logLevel: LogLevel.debug, + uniqueLabels: { baz: '1' }, + }, { entry: 'INFO 2', labels: { foo: 'bar', baz: '2' }, @@ -367,4 +367,96 @@ describe('dataFrameToLogsModel', () => { kind: LogsMetaKind.LabelsMap, }); }); + // + it('given multiple series with equal times should return expected logs model', () => { + const series: DataFrame[] = [ + toDataFrame({ + labels: { + foo: 'bar', + baz: '1', + level: 'dbug', + }, + fields: [ + { + name: 'ts', + type: FieldType.time, + values: ['1970-01-01T00:00:00Z'], + }, + { + name: 'line', + type: FieldType.string, + values: ['WARN boooo 1'], + }, + ], + }), + toDataFrame({ + labels: { + foo: 'bar', + baz: '2', + level: 'dbug', + }, + fields: [ + { + name: 'ts', + type: FieldType.time, + values: ['1970-01-01T00:00:01Z'], + }, + { + name: 'line', + type: FieldType.string, + values: ['WARN boooo 2'], + }, + ], + }), + toDataFrame({ + name: 'logs', + labels: { + foo: 'bar', + baz: '2', + level: 'err', + }, + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['1970-01-01T00:00:00Z', '1970-01-01T00:00:01Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['INFO 1', 'INFO 2'], + }, + ], + }), + ]; + const logsModel = dataFrameToLogsModel(series, 0); + expect(logsModel.hasUniqueLabels).toBeTruthy(); + expect(logsModel.rows).toHaveLength(4); + expect(logsModel.rows).toMatchObject([ + { + entry: 'WARN boooo 1', + labels: { foo: 'bar', baz: '1' }, + logLevel: LogLevel.debug, + uniqueLabels: { baz: '1' }, + }, + { + entry: 'INFO 1', + labels: { foo: 'bar', baz: '2' }, + logLevel: LogLevel.error, + uniqueLabels: { baz: '2' }, + }, + { + entry: 'WARN boooo 2', + labels: { foo: 'bar', baz: '2' }, + logLevel: LogLevel.debug, + uniqueLabels: { baz: '2' }, + }, + { + entry: 'INFO 2', + labels: { foo: 'bar', baz: '2' }, + logLevel: LogLevel.error, + uniqueLabels: { baz: '2' }, + }, + ]); + }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 25f5ca982cd7..534c9238db48 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -464,7 +464,7 @@ export const getRefIds = (value: any): string[] => { return _.uniq(_.flatten(refIds)); }; -const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { +export const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { if (a.timestamp < b.timestamp) { return -1; } From 5fdc6da3ec5b19e202be9cf844642efd0cebd220 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 13 Sep 2019 15:08:29 +0200 Subject: [PATCH 39/87] Prometheus: Fix response states (#19092) --- .../datasource/prometheus/datasource.test.ts | 126 ++++++++++++++++++ .../datasource/prometheus/datasource.ts | 30 +++-- 2 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 public/app/plugins/datasource/prometheus/datasource.test.ts diff --git a/public/app/plugins/datasource/prometheus/datasource.test.ts b/public/app/plugins/datasource/prometheus/datasource.test.ts new file mode 100644 index 000000000000..a381eb8c1e7c --- /dev/null +++ b/public/app/plugins/datasource/prometheus/datasource.test.ts @@ -0,0 +1,126 @@ +import { PrometheusDatasource } from './datasource'; +import { DataSourceInstanceSettings } from '@grafana/ui'; +import { PromOptions } from './types'; +import { dateTime, LoadingState } from '@grafana/data'; + +const defaultInstanceSettings: DataSourceInstanceSettings = { + url: 'test_prom', + jsonData: {}, +} as any; + +const backendSrvMock: any = { + datasourceRequest: jest.fn(), +}; + +const templateSrvMock: any = { + replace(): null { + return null; + }, + getAdhocFilters(): any[] { + return []; + }, +}; + +const timeSrvMock: any = { + timeRange(): any { + return { + from: dateTime(), + to: dateTime(), + }; + }, +}; + +describe('datasource', () => { + describe('query', () => { + const ds = new PrometheusDatasource( + defaultInstanceSettings, + {} as any, + backendSrvMock, + templateSrvMock, + timeSrvMock + ); + + it('returns empty array when no queries', done => { + expect.assertions(2); + ds.query(makeQuery([])).subscribe({ + next(next) { + expect(next.data).toEqual([]); + expect(next.state).toBe(LoadingState.Done); + }, + complete() { + done(); + }, + }); + }); + + it('performs time series queries', done => { + expect.assertions(2); + backendSrvMock.datasourceRequest.mockReturnValueOnce(Promise.resolve(makePromResponse())); + ds.query(makeQuery([{}])).subscribe({ + next(next) { + expect(next.data.length).not.toBe(0); + expect(next.state).toBe(LoadingState.Done); + }, + complete() { + done(); + }, + }); + }); + + it('with 2 queries, waits for all to finish until sending Done status', done => { + expect.assertions(4); + backendSrvMock.datasourceRequest.mockReturnValue(Promise.resolve(makePromResponse())); + const responseStatus = [LoadingState.Loading, LoadingState.Done]; + ds.query(makeQuery([{}, {}])).subscribe({ + next(next) { + expect(next.data.length).not.toBe(0); + expect(next.state).toBe(responseStatus.shift()); + }, + complete() { + done(); + }, + }); + }); + }); +}); + +function makeQuery(targets: any[]): any { + return { + targets: targets.map(t => { + return { + instant: false, + start: dateTime().subtract(5, 'minutes'), + end: dateTime(), + expr: 'test', + ...t, + }; + }), + range: { + from: dateTime(), + to: dateTime(), + }, + interval: '15s', + }; +} + +/** + * Creates a pretty bogus prom response. Definitelly needs more work but right now we do not test the contents of the + * messages anyway. + */ +function makePromResponse() { + return { + data: { + data: { + result: [ + { + metric: { + __name__: 'test_metric', + }, + values: [[1568369640, 1]], + }, + ], + resultType: 'matrix', + }, + }, + }; +} diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 98ac07ff94de..242039980465 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -3,9 +3,9 @@ import _ from 'lodash'; import $ from 'jquery'; // Services & Utils import kbn from 'app/core/utils/kbn'; -import { dateMath, TimeRange, DateTime, AnnotationEvent } from '@grafana/data'; -import { Observable, from, of, merge } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { AnnotationEvent, dateMath, DateTime, LoadingState, TimeRange } from '@grafana/data'; +import { from, merge, Observable, of } from 'rxjs'; +import { filter, map, tap } from 'rxjs/operators'; import PrometheusMetricFindQuery from './metric_find_query'; import { ResultTransformer } from './result_transformer'; @@ -15,20 +15,19 @@ import addLabelToQuery from './add_label_to_query'; import { getQueryHints } from './query_hints'; import { expandRecordingRules } from './language_utils'; // Types -import { PromQuery, PromOptions, PromQueryRequest, PromContext } from './types'; +import { PromContext, PromOptions, PromQuery, PromQueryRequest } from './types'; import { + DataQueryError, DataQueryRequest, + DataQueryResponse, + DataQueryResponseData, DataSourceApi, DataSourceInstanceSettings, - DataQueryError, - DataQueryResponseData, - DataQueryResponse, } from '@grafana/ui'; import { safeStringifyValue } from 'app/core/utils/explore'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { ExploreUrlState } from 'app/types'; -import { LoadingState } from '@grafana/data/src/types/data'; export interface PromDataQueryResponse { data: { @@ -227,16 +226,16 @@ export class PrometheusDatasource extends DataSourceApi // No valid targets, return the empty result to save a round trip. if (_.isEmpty(queries)) { - return of({ data: [] }); + return of({ + data: [], + state: LoadingState.Done, + }); } - const allInstant = queries.filter(query => query.instant).length === queries.length; - const allTimeSeries = queries.filter(query => !query.instant).length === queries.length; + let runningQueriesCount = queries.length; const subQueries = queries.map((query, index) => { const target = activeTargets[index]; let observable: Observable = null; - const state: LoadingState = - allInstant || allTimeSeries ? LoadingState.Done : query.instant ? LoadingState.Loading : LoadingState.Done; if (query.instant) { observable = from(this.performInstantQuery(query, end)); @@ -245,13 +244,16 @@ export class PrometheusDatasource extends DataSourceApi } return observable.pipe( + // Decrease the counter here. We assume that each request returns only single value and then completes + // (should hold until there is some streaming requests involved). + tap(() => runningQueriesCount--), filter((response: any) => (response.cancelled ? false : true)), map((response: any) => { const data = this.processResult(response, query, target, queries.length); return { data, key: query.requestId, - state, + state: runningQueriesCount === 0 ? LoadingState.Done : LoadingState.Loading, } as DataQueryResponse; }) ); From d55261aac765e8234c90ef7b3000158d48a967c1 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 13 Sep 2019 15:32:53 +0200 Subject: [PATCH 40/87] Explore: Move throttling before processing (#19095) --- public/app/features/explore/state/actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index b6de5dbd0e54..46ac0a9331ba 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -482,11 +482,11 @@ export function runQueries(exploreId: ExploreId): ThunkResult { const newQuerySub = runRequest(datasourceInstance, transaction.request) .pipe( - map((data: PanelData) => preProcessPanelData(data, queryResponse)), // Simple throttle for live tailing, in case of > 1000 rows per interval we spend about 200ms on processing and // rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user // actually can see what is happening. - live ? throttleTime(500) : identity + live ? throttleTime(500) : identity, + map((data: PanelData) => preProcessPanelData(data, queryResponse)) ) .subscribe((data: PanelData) => { if (!data.error && firstResponse) { From fc10bd7b8e7b5d775a9bb664576dee5f2e0a516f Mon Sep 17 00:00:00 2001 From: lzd Date: Fri, 13 Sep 2019 22:15:31 +0800 Subject: [PATCH 41/87] =?UTF-8?q?Singlestat:=20fix=20format=20messes=20up?= =?UTF-8?q?=20on=20negative=20values=20if=20select=20duratio=E2=80=A6=20(#?= =?UTF-8?q?19044)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Singlestat: fix format messes up on negative values if select duration (hh:mm:ss) unit * Added test for 0 --- .../utils/valueFormats/dateTimeFormatters.test.ts | 13 +++++++++++++ .../src/utils/valueFormats/dateTimeFormatters.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts index 2fa851867f57..1640b63d907d 100644 --- a/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts +++ b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts @@ -7,6 +7,7 @@ import { toDuration, toDurationInMilliseconds, toDurationInSeconds, + toDurationInHoursMinutesSeconds, } from './dateTimeFormatters'; import { toUtc, dateTime } from '@grafana/data'; @@ -161,6 +162,18 @@ describe('duration', () => { const str = toDuration(36993906007, 8, Interval.Millisecond); expect(str).toBe('1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds'); }); + it('1 dthms', () => { + const str = toDurationInHoursMinutesSeconds(1); + expect(str).toBe('00:00:01'); + }); + it('-1 dthms', () => { + const str = toDurationInHoursMinutesSeconds(-1); + expect(str).toBe('00:00:01 ago'); + }); + it('0 dthms', () => { + const str = toDurationInHoursMinutesSeconds(0); + expect(str).toBe('00:00:00'); + }); }); describe('clock', () => { diff --git a/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts index 451879242503..20ffbab6509e 100644 --- a/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts +++ b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts @@ -283,7 +283,10 @@ export function toDurationInSeconds(size: number, decimals: DecimalCount) { return toDuration(size, decimals, Interval.Second); } -export function toDurationInHoursMinutesSeconds(size: number) { +export function toDurationInHoursMinutesSeconds(size: number): string { + if (size < 0) { + return toDurationInHoursMinutesSeconds(-size) + ' ago'; + } const strings = []; const numHours = Math.floor(size / 3600); const numMinutes = Math.floor((size % 3600) / 60); From fd21e0ba14dac32ae703dd6c73d93c84eeb881e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 13 Sep 2019 16:38:21 +0200 Subject: [PATCH 42/87] DataLinks: enable access to labels & field names (#18918) * POC: trying to see if there is a way to support objects in template interpolations * Added support for nested objects, and arrays * Added accessor cache * fixed unit tests * First take * Use links supplier in graph * Add field's index to cache items * Get field index from field cache * CHange FiledCacheItem to FieldWithIndex * Add refId to TimeSeries class * Make field link supplier work with _series, _field and _value vars * use field link supplier in graph * Fix yaxis settings * Update dashboard schema version and add migration for data links variables * Update snapshots * Update build in data link variables * FieldCache - idx -> index * Add current query results to panel editor * WIP Updated data links dropdown to display new variables * Fix build * Update variables syntac in field display, update migration * Field links supplier: review updates * Add data frame view and field name to TimeSeries for later inspection * Retrieve data frame from TimeSeries when clicking on plot graph * Use data frame's index instead of view * Retrieve data frame by index instead of view on TimeSeries * Update data links prism regex * Fix typecheck * Add value variables to suggestions list * UI update * Rename field to config in DisplayProcessorOptions * Proces single value of a field instead of entire data frame * Updated font size from 10px to 12px for auto complete * Replace fieldName with fieldIndex in TimeSeries * Don't use .entries() for iterating in field cache * Don't use FieldCache when retrieving field for datalinks in graph * Add value calculation variable to data links (#19031) * Add support for labels with dots in the name (#19033) * Docs update * Use field name instead of removed series.fieldName * Add test dashboard * Typos fix * Make visualization tab subscribe to query results * Added tags to dashboard so it shows up in lists * minor docs fix * Update singlestat-ish variables suggestions to contain series variables * Decrease suggestions update debounce * Enable whitespace characters(new line, space) in links and strip them when processing the data link * minor data links UI update * DataLinks: Add __from and __to variables suggestions to data links (#19093) * Add from and to variables suggestions to data links * Update docs * UI update and added info text * Change ESC global bind to bind (doesn't capture ESC on input) * Close datalinks suggestions on ESC * Remove unnecessary fragment --- .../testdata-datalinks.json | 510 ++++++++++++++++++ docs/sources/features/panels/graph.md | 42 +- .../grafana-data/src/utils/dataFrameHelper.ts | 7 +- .../components/DataLinks/DataLinkEditor.tsx | 71 +-- .../components/DataLinks/DataLinkInput.tsx | 12 +- .../DataLinks/DataLinkSuggestions.tsx | 90 ++-- .../components/DataLinks/DataLinksEditor.tsx | 3 +- .../src/components/FormField/FormField.tsx | 5 +- .../src/components/List/AbstractList.tsx | 4 +- .../FieldPropertiesEditor.tsx | 4 +- packages/grafana-ui/src/types/panel.ts | 1 + packages/grafana-ui/src/utils/dataLinks.ts | 12 +- .../src/utils/displayProcessor.test.ts | 26 +- .../grafana-ui/src/utils/displayProcessor.ts | 4 +- packages/grafana-ui/src/utils/fieldDisplay.ts | 26 +- public/app/core/logs_model.ts | 4 +- public/app/core/services/keybindingSrv.ts | 2 +- .../__snapshots__/DashboardPage.test.tsx.snap | 10 +- .../__snapshots__/DashboardGrid.test.tsx.snap | 8 +- .../panel_editor/VisualizationTab.tsx | 24 +- .../dashboard/state/DashboardMigrator.test.ts | 67 ++- .../dashboard/state/DashboardMigrator.ts | 52 +- .../panel/panellinks/linkSuppliers.ts | 82 ++- .../app/features/panel/panellinks/link_srv.ts | 122 ++++- .../panel/panellinks/specs/link_srv.test.ts | 16 +- .../templating/specs/template_srv.test.ts | 21 + .../app/features/templating/template_srv.ts | 46 +- public/app/features/templating/variable.ts | 2 +- .../panel/bargauge/BarGaugePanelEditor.tsx | 4 +- .../plugins/panel/gauge/GaugePanelEditor.tsx | 5 +- .../panel/graph/GraphContextMenuCtrl.ts | 11 +- public/app/plugins/panel/graph/graph.ts | 128 +++-- public/app/plugins/panel/graph/module.ts | 8 +- .../plugins/panel/graph/specs/graph.test.ts | 2 +- public/app/plugins/panel/graph/template.ts | 2 +- .../panel/graph2/getGraphSeriesModel.ts | 2 +- .../panel/piechart/PieChartPanelEditor.tsx | 4 +- public/app/plugins/panel/singlestat/module.ts | 2 +- .../panel/singlestat2/SingleStatEditor.tsx | 4 +- 39 files changed, 1174 insertions(+), 271 deletions(-) create mode 100644 devenv/dev-dashboards/feature-templating/testdata-datalinks.json diff --git a/devenv/dev-dashboards/feature-templating/testdata-datalinks.json b/devenv/dev-dashboards/feature-templating/testdata-datalinks.json new file mode 100644 index 000000000000..0f425bab088e --- /dev/null +++ b/devenv/dev-dashboards/feature-templating/testdata-datalinks.json @@ -0,0 +1,510 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "iteration": 1568372030444, + "links": [], + "panels": [ + { + "content": "## Data link variables overview\n\nThis dashboard presents variables that one can use when creating *data links*. All links redirect to this dashboard and this panel represents the values that were interpolated in the link that was clicked.\n\n\n#### Series variables\n1. **Name:** $seriesName\n2. **label.datacenter:** $labelDatacenter\n3. **label.datacenter.region:** $labelDatacenterRegion\n\n#### Field variables\n1. **Name:** $fieldName\n\n#### Value variables\n1. **Time:** $valueTime\n2. **Numeric:** $valueNumeric\n3. **Text:** $valueText\n4. **Calc:** $valueCalc\n\n", + "gridPos": { + "h": 16, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 8, + "mode": "markdown", + "options": {}, + "timeFrom": null, + "timeShift": null, + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 9, + "x": 6, + "y": 0 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [ + { + "targetBlank": false, + "title": "Drill it down", + "url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source?var-seriesName=${__series.name}&var-labelDatacenter=${__series.labels.datacenter}&var-labelDatacenterRegion=${__series.labels[\"datacenter.region\"]}&var-valueTime=${__value.time}&var-valueNumeric=${__value.numeric}&var-valueText=${__value.text}" + } + ] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "Foo datacenter", + "labels": "datacenter=foo,datacenter.region=us-east-1", + "refId": "A", + "scenarioId": "random_walk" + }, + { + "alias": "Bar datacenter", + "labels": "datacenter=bar,datacenter.region=us-east-2", + "refId": "B", + "scenarioId": "random_walk" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Multiple series", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 0 + }, + "id": 9, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [ + { + "targetBlank": false, + "title": "Drill it down", + "url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source?var-seriesName=${__series.name}&var-valueTime=${__value.time}&var-valueNumeric=${__value.numeric}&var-valueText=${__value.text}&var-fieldName=${__field.name}" + } + ] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "Foo datacenter", + "labels": "datacenter=foo,datacenter.region=us-east-1", + "refId": "A", + "scenarioId": "random_walk_table", + "stringInput": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Multiple fields", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "cacheTimeout": null, + "datasource": "-- Dashboard --", + "gridPos": { + "h": 8, + "w": 9, + "x": 6, + "y": 8 + }, + "id": 6, + "links": [], + "options": { + "displayMode": "lcd", + "fieldOptions": { + "calcs": ["last"], + "defaults": { + "links": [ + { + "targetBlank": true, + "title": "Drill it down!", + "url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source\n?var-fieldName=${__field.name}\n&var-labelDatacenter=${__series.labels.datacenter}\n&var-labelDatacenterRegion=${__series.labels[\"datacenter.region\"]}\n&var-valueNumeric=${__value.numeric}\n&var-valueText=${__value.text}\n&var-valueCalc=${__value.calc}" + } + ], + "mappings": [ + { + "id": 0, + "op": "=", + "text": "N/A", + "type": 1, + "value": "null" + } + ], + "max": 100, + "min": 0, + "nullValueMode": "connected", + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ], + "title": "${__series.name} - $__calc", + "unit": "none" + }, + "override": {}, + "values": false + }, + "orientation": "horizontal" + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "panelId": 2, + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Value reducers 1", + "type": "bargauge" + }, + { + "datasource": "-- Dashboard --", + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 8 + }, + "id": 4, + "options": { + "fieldOptions": { + "calcs": ["mean"], + "defaults": { + "links": [ + { + "title": "Drill it down", + "url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source?var-fieldName=${__field.name}&var-labelDatacenter=${__series.labels.datacenter}&var-labelDatacenterRegion=${__series.labels[\"datacenter.region\"]}&var-valueNumeric=${__value.numeric}&var-valueText=${__value.text}&var-valueCalc=${__value.calc}" + } + ], + "mappings": [], + "max": 100, + "min": 0, + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ], + "title": "${__series.name} - $__calc" + }, + "override": {}, + "values": false + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "panelId": 2, + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Value reducers 2", + "type": "gauge" + } + ], + "schemaVersion": 20, + "style": "dark", + "tags": ["gdev", "templating"], + "templating": { + "list": [ + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": "Series name", + "name": "seriesName", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "labelDatacenter", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "labelDatacenterRegion", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "valueTime", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "valueNumeric", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "valueText", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "valueCalc", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "hide": 2, + "label": null, + "name": "fieldName", + "options": [ + { + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Datalinks - variables", + "uid": "wfTJJL5Wz", + "version": 1 +} diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 7241bd30c867..33a51274454d 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -192,7 +192,7 @@ Panel time overrides & timeshift are described in more detail [here]({{< relref > Only available in Grafana v6.3+. -Data link in graph settings allows adding dynamic links to the visualization. Those links can link to either other dashboard or to an external URL. +Data link allows adding dynamic links to the visualization. Those links can link to either other dashboard or to an external URL. {{< docs-imagebox img="/img/docs/data_link.png" max-width= "800px" >}} @@ -208,14 +208,40 @@ available suggestions: {{< docs-imagebox img="/img/docs/data_link_typeahead.png" max-width= "800px" >}} -Available built-in variables are: +#### Built-in variables -1. ``__all_variables`` - will add all current dashboard's variables to the URL -2. ``__url_time_range`` - will add current dashboard's time range to the URL (i.e. ``?from=now-6h&to=now``) -3. ``__series_name`` - will add series name as a query param in the URL (i.e. ``?series=B-series``) -4. ``__value_time`` - will add datapoint's timestamp (Unix ms epoch) to the URL (i.e. ``?time=1560268814105``) +``__url_time_range`` - current dashboard's time range (i.e. ``?from=now-6h&to=now``) +``__from`` - current dashboard's time range from value +``__to`` - current dashboard's time range to value +#### Series variables +Series specific variables are available under ``__series`` namespace: + +``__series.name`` - series name to the URL + +``__series.labels.
); }; diff --git a/packages/grafana-ui/src/components/List/AbstractList.tsx b/packages/grafana-ui/src/components/List/AbstractList.tsx index 7c978753276a..643e6cf398df 100644 --- a/packages/grafana-ui/src/components/List/AbstractList.tsx +++ b/packages/grafana-ui/src/components/List/AbstractList.tsx @@ -36,10 +36,10 @@ export class AbstractList extends React.PureComponent> { } render() { - const { items, renderItem, getItemKey } = this.props; + const { items, renderItem, getItemKey, className } = this.props; const styles = this.getListStyles(); return ( -
    +
      {items.map((item, i) => { return (
    • diff --git a/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx b/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx index a7f8a858a449..7eaa964e080f 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx +++ b/packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx @@ -70,9 +70,9 @@ export const FieldPropertiesEditor: React.FC = ({ value, onChange, showMi
      Template Variables:
      - {'$' + VAR_SERIES_NAME} + {'${' + VAR_SERIES_NAME + '}'}
      - {'$' + VAR_FIELD_NAME} + {'${' + VAR_FIELD_NAME + '}'}
      {'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
      diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index a104122c6fb7..440c55c664ac 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -35,6 +35,7 @@ export interface PanelProps { export interface PanelEditorProps { options: T; onOptionsChange: (options: T) => void; + data: PanelData; } export interface PanelModel { diff --git a/packages/grafana-ui/src/utils/dataLinks.ts b/packages/grafana-ui/src/utils/dataLinks.ts index 38f212a745a6..74f22ed4885f 100644 --- a/packages/grafana-ui/src/utils/dataLinks.ts +++ b/packages/grafana-ui/src/utils/dataLinks.ts @@ -3,9 +3,17 @@ import { LinkModelSupplier } from '@grafana/data'; export const DataLinkBuiltInVars = { keepTime: '__url_time_range', + timeRangeFrom: '__from', + timeRangeTo: '__to', includeVars: '__all_variables', - seriesName: '__series_name', - valueTime: '__value_time', + seriesName: '__series.name', + fieldName: '__field.name', + valueTime: '__value.time', + valueNumeric: '__value.numeric', + valueText: '__value.text', + valueRaw: '__value.raw', + // name of the calculation represented by the value + valueCalc: '__value.calc', }; /** diff --git a/packages/grafana-ui/src/utils/displayProcessor.test.ts b/packages/grafana-ui/src/utils/displayProcessor.test.ts index 3286caa322df..1c59d7803908 100644 --- a/packages/grafana-ui/src/utils/displayProcessor.test.ts +++ b/packages/grafana-ui/src/utils/displayProcessor.test.ts @@ -18,10 +18,10 @@ describe('Process simple display values', () => { getDisplayProcessor(), // Add a simple option that is not used (uses a different base class) - getDisplayProcessor({ field: { min: 0, max: 100 } }), + getDisplayProcessor({ config: { min: 0, max: 100 } }), // Add a simple option that is not used (uses a different base class) - getDisplayProcessor({ field: { unit: 'locale' } }), + getDisplayProcessor({ config: { unit: 'locale' } }), ]; it('support null', () => { @@ -102,7 +102,7 @@ describe('Format value', () => { it('should return if value isNaN', () => { const valueMappings: ValueMapping[] = []; const value = 'N/A'; - const instance = getDisplayProcessor({ field: { mappings: valueMappings } }); + const instance = getDisplayProcessor({ config: { mappings: valueMappings } }); const result = instance(value); @@ -113,7 +113,7 @@ describe('Format value', () => { const valueMappings: ValueMapping[] = []; const value = '6'; - const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } }); + const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } }); const result = instance(value); @@ -126,7 +126,7 @@ describe('Format value', () => { { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, ]; const value = '10'; - const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } }); + const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } }); const result = instance(value); @@ -135,20 +135,20 @@ describe('Format value', () => { it('should set auto decimals, 1 significant', () => { const value = 3.23; - const instance = getDisplayProcessor({ field: { decimals: null } }); + const instance = getDisplayProcessor({ config: { decimals: null } }); expect(instance(value).text).toEqual('3.2'); }); it('should set auto decimals, 2 significant', () => { const value = 0.0245; - const instance = getDisplayProcessor({ field: { decimals: null } }); + const instance = getDisplayProcessor({ config: { decimals: null } }); expect(instance(value).text).toEqual('0.025'); }); it('should use override decimals', () => { const value = 100030303; - const instance = getDisplayProcessor({ field: { decimals: 2, unit: 'bytes' } }); + const instance = getDisplayProcessor({ config: { decimals: 2, unit: 'bytes' } }); expect(instance(value).text).toEqual('95.40 MiB'); }); @@ -158,7 +158,7 @@ describe('Format value', () => { { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, ]; const value = '11'; - const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } }); + const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } }); expect(instance(value).text).toEqual('1-20'); }); @@ -169,25 +169,25 @@ describe('Format value', () => { it('with value 1000 and unit short', () => { const value = 1000; - const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } }); + const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } }); expect(instance(value).text).toEqual('1.000 K'); }); it('with value 1200 and unit short', () => { const value = 1200; - const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } }); + const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } }); expect(instance(value).text).toEqual('1.200 K'); }); it('with value 1250 and unit short', () => { const value = 1250; - const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } }); + const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } }); expect(instance(value).text).toEqual('1.250 K'); }); it('with value 10000000 and unit short', () => { const value = 1000000; - const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } }); + const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } }); expect(instance(value).text).toEqual('1.000 Mil'); }); }); diff --git a/packages/grafana-ui/src/utils/displayProcessor.ts b/packages/grafana-ui/src/utils/displayProcessor.ts index 831cb3cda479..b4aafdae5462 100644 --- a/packages/grafana-ui/src/utils/displayProcessor.ts +++ b/packages/grafana-ui/src/utils/displayProcessor.ts @@ -18,7 +18,7 @@ import { getColorFromHexRgbOrName } from './namedColorsPalette'; import { GrafanaTheme, GrafanaThemeType } from '../types/index'; interface DisplayProcessorOptions { - field?: FieldConfig; + config?: FieldConfig; // Context isUtc?: boolean; @@ -27,7 +27,7 @@ interface DisplayProcessorOptions { export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayProcessor { if (options && !_.isEmpty(options)) { - const field = options.field ? options.field : {}; + const field = options.config ? options.config : {}; const formatFunc = getValueFormat(field.unit || 'none'); return (value: any) => { diff --git a/packages/grafana-ui/src/utils/fieldDisplay.ts b/packages/grafana-ui/src/utils/fieldDisplay.ts index cdbb97d01843..90fdd7c00278 100644 --- a/packages/grafana-ui/src/utils/fieldDisplay.ts +++ b/packages/grafana-ui/src/utils/fieldDisplay.ts @@ -17,7 +17,6 @@ import toString from 'lodash/toString'; import { GrafanaTheme, InterpolateFunction } from '../types/index'; import { getDisplayProcessor } from './displayProcessor'; import { getFlotPairs } from './flotPairs'; -import { DataLinkBuiltInVars } from '../utils/dataLinks'; export interface FieldDisplayOptions { values?: boolean; // If true show each row value @@ -28,8 +27,8 @@ export interface FieldDisplayOptions { override: FieldConfig; // Set these values regardless of the source } // TODO: use built in variables, same as for data links? -export const VAR_SERIES_NAME = '__series_name'; -export const VAR_FIELD_NAME = '__field_name'; +export const VAR_SERIES_NAME = '__series.name'; +export const VAR_FIELD_NAME = '__field.name'; export const VAR_CALC = '__calc'; export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates @@ -54,7 +53,7 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat parts.push('$' + VAR_CALC); } if (data.length > 1) { - parts.push('$' + VAR_SERIES_NAME); + parts.push('${' + VAR_SERIES_NAME + '}'); } if (fieldCount > 1 || !parts.length) { parts.push('$' + VAR_FIELD_NAME); @@ -70,8 +69,8 @@ export interface FieldDisplay { // Expose to the original values for delayed inspection (DataLinks etc) view?: DataFrameView; - column?: number; // The field column index - row?: number; // only filled in when the value is from a row (ie, not a reduction) + colIndex?: number; // The field column index + rowIndex?: number; // only filled in when the value is from a row (ie, not a reduction) } export interface GetFieldDisplayValuesOptions { @@ -106,7 +105,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi }; } - scopedVars[DataLinkBuiltInVars.seriesName] = { text: 'Series', value: series.name }; + scopedVars['__series'] = { text: 'Series', value: { name: series.name } }; const { timeField } = getTimeField(series); const view = new DataFrameView(series); @@ -125,15 +124,14 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi name = `Field[${s}]`; } - scopedVars[VAR_FIELD_NAME] = { text: 'Field', value: name }; + scopedVars['__field'] = { text: 'Field', value: { name } }; const display = getDisplayProcessor({ - field: config, + config, theme: options.theme, }); const title = config.title ? config.title : defaultTitle; - // Show all rows if (fieldOptions.values) { const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0; @@ -158,8 +156,8 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi field: config, display: displayValue, view, - column: i, - row: j, + colIndex: i, + rowIndex: j, }); if (values.length >= limit) { @@ -187,12 +185,12 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi const displayValue = display(results[calc]); displayValue.title = replaceVariables(title, scopedVars); values.push({ - name, + name: calc, field: config, display: displayValue, sparkline, view, - column: i, + colIndex: i, }); } } diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 526da09a3eb5..b0a759fa1935 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -246,7 +246,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { hasUniqueLabels = true; } - const timeFieldIndex = fieldCache.getFirstFieldOfType(FieldType.time); + const timeField = fieldCache.getFirstFieldOfType(FieldType.time); const stringField = fieldCache.getFirstFieldOfType(FieldType.string); const logLevelField = fieldCache.getFieldByName('level'); @@ -256,7 +256,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { } for (let j = 0; j < series.length; j++) { - const ts = timeFieldIndex.values.get(j); + const ts = timeField.values.get(j); const time = dateTime(ts); const timeEpochMs = time.valueOf(); const timeFromNow = time.fromNow(); diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 59d342c6c406..f3aa11f22988 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -46,7 +46,7 @@ export class KeybindingSrv { this.bind('g p', this.goToProfile); this.bind('s o', this.openSearch); this.bind('f', this.openSearch); - this.bindGlobal('esc', this.exit); + this.bind('esc', this.exit); } } diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap index 26299511b614..75f87f04099d 100644 --- a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -78,7 +78,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -191,7 +191,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -315,7 +315,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -426,7 +426,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -521,7 +521,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], diff --git a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap index 570595bd4b87..271b0f01b92e 100644 --- a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap +++ b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap @@ -232,7 +232,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -469,7 +469,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -706,7 +706,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], @@ -943,7 +943,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ], "refresh": undefined, "revision": undefined, - "schemaVersion": 19, + "schemaVersion": 20, "snapshot": undefined, "style": "dark", "tags": Array [], diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index 739f99bd95b4..01c7408ea026 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -18,8 +18,10 @@ import { PanelModel } from '../state'; import { DashboardModel } from '../state'; import { VizPickerSearch } from './VizPickerSearch'; import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; -import { PanelPlugin, PanelPluginMeta } from '@grafana/ui'; +import { PanelPlugin, PanelPluginMeta, PanelData } from '@grafana/ui'; import { PanelCtrl } from 'app/plugins/sdk'; +import { Unsubscribable } from 'rxjs'; +import { LoadingState } from '@grafana/data'; interface Props { panel: PanelModel; @@ -36,11 +38,13 @@ interface State { searchQuery: string; scrollTop: number; hasBeenFocused: boolean; + data: PanelData; } export class VisualizationTab extends PureComponent { element: HTMLElement; angularOptions: AngularComponent; + querySubscription: Unsubscribable; constructor(props: Props) { super(props); @@ -50,6 +54,10 @@ export class VisualizationTab extends PureComponent { hasBeenFocused: false, searchQuery: '', scrollTop: 0, + data: { + state: LoadingState.NotStarted, + series: [], + }, }; } @@ -66,16 +74,28 @@ export class VisualizationTab extends PureComponent { } if (plugin.editor) { - return ; + return ( + + ); } return

      Visualization has no options

      ; } componentDidMount() { + const { panel } = this.props; + const queryRunner = panel.getQueryRunner(); if (this.shouldLoadAngularOptions()) { this.loadAngularOptions(); } + + this.querySubscription = queryRunner.getData().subscribe({ + next: (data: PanelData) => this.setState({ data }), + }); } componentDidUpdate(prevProps: Props) { diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index c1764522d32e..00bd5f22299a 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -128,7 +128,7 @@ describe('DashboardModel', () => { }); it('dashboard schema version should be set to latest', () => { - expect(model.schemaVersion).toBe(19); + expect(model.schemaVersion).toBe(20); }); it('graph thresholds should be migrated', () => { @@ -441,6 +441,71 @@ describe('DashboardModel', () => { expect(model.panels[0].links[3].url).toBe(`/dashboard/db/my-other-dashboard`); }); }); + + describe('when migrating variables', () => { + let model: any; + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + //graph panel + options: { + dataLinks: [ + { + url: 'http://mylink.com?series=${__series_name}', + }, + { + url: 'http://mylink.com?series=${__value_time}', + }, + ], + }, + }, + { + // panel with field options + options: { + fieldOptions: { + defaults: { + links: [ + { + url: 'http://mylink.com?series=${__series_name}', + }, + { + url: 'http://mylink.com?series=${__value_time}', + }, + ], + title: '$__cell_0 * $__field_name * $__series_name', + }, + }, + }, + }, + ], + }); + }); + + describe('data links', () => { + it('should replace __series_name variable with __series.name', () => { + expect(model.panels[0].options.dataLinks[0].url).toBe('http://mylink.com?series=${__series.name}'); + expect(model.panels[1].options.fieldOptions.defaults.links[0].url).toBe( + 'http://mylink.com?series=${__series.name}' + ); + }); + + it('should replace __value_time variable with __value.time', () => { + expect(model.panels[0].options.dataLinks[1].url).toBe('http://mylink.com?series=${__value.time}'); + expect(model.panels[1].options.fieldOptions.defaults.links[1].url).toBe( + 'http://mylink.com?series=${__value.time}' + ); + }); + }); + + describe('field display', () => { + it('should replace __series_name and __field_name variables with new syntax', () => { + expect(model.panels[1].options.fieldOptions.defaults.title).toBe( + '$__cell_0 * ${__field.name} * ${__series.name}' + ); + }); + }); + }); }); function createRow(options: any, panelDescriptions: any[]) { diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index c57d75ab7542..319f85380694 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -33,7 +33,7 @@ export class DashboardMigrator { let i, j, k, n; const oldVersion = this.dashboard.schemaVersion; const panelUpgrades = []; - this.dashboard.schemaVersion = 19; + this.dashboard.schemaVersion = 20; if (oldVersion === this.dashboard.schemaVersion) { return; @@ -436,6 +436,33 @@ export class DashboardMigrator { }); } + if (oldVersion < 20) { + const updateLinks = (link: DataLink) => { + return { + ...link, + url: updateVariablesSyntax(link.url), + }; + }; + panelUpgrades.push((panel: any) => { + // For graph panel + if (panel.options && panel.options.dataLinks && _.isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + + // For panel with fieldOptions + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (panel.options.fieldOptions.defaults.links && _.isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + if (panel.options.fieldOptions.defaults.title) { + panel.options.fieldOptions.defaults.title = updateVariablesSyntax( + panel.options.fieldOptions.defaults.title + ); + } + } + }); + } + if (panelUpgrades.length === 0) { return; } @@ -666,3 +693,26 @@ function upgradePanelLink(link: any): DataLink { targetBlank: link.targetBlank, }; } + +function updateVariablesSyntax(text: string) { + const legacyVariableNamesRegex = /(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)/g; + + return text.replace(legacyVariableNamesRegex, (match, seriesName, seriesName1, valueTime, fieldName, fieldName1) => { + if (seriesName) { + return '__series.name'; + } + if (seriesName1) { + return '${__series.name}'; + } + if (valueTime) { + return '__value.time'; + } + if (fieldName) { + return '__field.name'; + } + if (fieldName1) { + return '${__field.name}'; + } + return match; + }); +} diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index bdd5d14d0a99..fa0fa60fe531 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -1,8 +1,32 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { FieldDisplay, DataLinkBuiltInVars } from '@grafana/ui'; -import { LinkModelSupplier, getTimeField, ScopedVars } from '@grafana/data'; +import { FieldDisplay } from '@grafana/ui'; +import { LinkModelSupplier, getTimeField, Labels, ScopedVars, ScopedVar } from '@grafana/data'; import { getLinkSrv } from './link_srv'; +interface SeriesVars { + name?: string; + labels?: Labels; + refId?: string; +} + +interface FieldVars { + name: string; +} + +interface ValueVars { + raw: any; + numeric: number; + text: string; + time?: number; + calc?: string; +} + +interface DataLinkScopedVars extends ScopedVars { + __series?: ScopedVar; + __field?: ScopedVar; + __value?: ScopedVar; +} + /** * Link suppliers creates link models based on a link origin */ @@ -14,29 +38,53 @@ export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier { - const scopedVars: ScopedVars = {}; - // TODO, add values to scopedVars and/or pass objects to event listeners + const scopedVars: DataLinkScopedVars = {}; + if (value.view) { - scopedVars[DataLinkBuiltInVars.seriesName] = { + const { dataFrame } = value.view; + + scopedVars['__series'] = { + value: { + name: dataFrame.name, + labels: dataFrame.labels, + refId: dataFrame.refId, + }, text: 'Series', - value: value.view.dataFrame.name, }; - const field = value.column ? value.view.dataFrame.fields[value.column] : undefined; + + const field = value.colIndex !== undefined ? dataFrame.fields[value.colIndex] : undefined; if (field) { console.log('Full Field Info:', field); + scopedVars['__field'] = { + value: { + name: field.name, + }, + text: 'Field', + }; } - if (value.row) { - const row = value.view.get(value.row); - console.log('ROW:', row); - const dataFrame = value.view.dataFrame; + if (value.rowIndex) { const { timeField } = getTimeField(dataFrame); - if (timeField) { - scopedVars[DataLinkBuiltInVars.valueTime] = { - text: 'Value time', - value: timeField.values.get(value.row), - }; - } + scopedVars['__value'] = { + value: { + raw: field.values.get(value.rowIndex), + numeric: value.display.numeric, + text: value.display.text, + time: timeField ? timeField.values.get(value.rowIndex) : undefined, + }, + text: 'Value', + }; + } else { + // calculation + scopedVars['__value'] = { + value: { + raw: value.display.numeric, + numeric: value.display.numeric, + text: value.display.text, + calc: value.name, + }, + text: 'Value', + }; } } else { console.log('VALUE', value); diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index 8a94ba2dba75..c586d55fe1d5 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -4,48 +4,119 @@ import templateSrv, { TemplateSrv } from 'app/features/templating/template_srv'; import coreModule from 'app/core/core_module'; import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url'; import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui'; -import { DataLink, KeyValue, deprecationWarning, LinkModel, ScopedVars } from '@grafana/data'; +import { DataLink, KeyValue, deprecationWarning, LinkModel, DataFrame, ScopedVars } from '@grafana/data'; -export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [ - ...templateSrv.variables.map(variable => ({ - value: variable.name as string, - origin: VariableOrigin.Template, - })), +const timeRangeVars = [ { - value: `${DataLinkBuiltInVars.includeVars}`, - documentation: 'Adds current variables', + value: `${DataLinkBuiltInVars.keepTime}`, + label: 'Time range', + documentation: 'Adds current time range', origin: VariableOrigin.BuiltIn, }, { - value: `${DataLinkBuiltInVars.keepTime}`, - documentation: 'Adds current time range', + value: `${DataLinkBuiltInVars.timeRangeFrom}`, + label: 'Time range: from', + documentation: "Adds current time range's from value", + origin: VariableOrigin.BuiltIn, + }, + { + value: `${DataLinkBuiltInVars.timeRangeTo}`, + label: 'Time range: to', + documentation: "Adds current time range's to value", origin: VariableOrigin.BuiltIn, }, ]; -export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [ - ...getPanelLinksVariableSuggestions(), +const fieldVars = [ { - value: `${DataLinkBuiltInVars.seriesName}`, - documentation: 'Adds series name', - origin: VariableOrigin.BuiltIn, + value: `${DataLinkBuiltInVars.fieldName}`, + label: 'Name', + documentation: 'Field name of the clicked datapoint (in ms epoch)', + origin: VariableOrigin.Field, }, +]; + +const valueVars = [ { - value: `${DataLinkBuiltInVars.valueTime}`, - documentation: 'Time value of the clicked datapoint (in ms epoch)', - origin: VariableOrigin.BuiltIn, + value: `${DataLinkBuiltInVars.valueNumeric}`, + label: 'Numeric', + documentation: 'Numeric representation of selected value', + origin: VariableOrigin.Value, + }, + { + value: `${DataLinkBuiltInVars.valueText}`, + label: 'Text', + documentation: 'Text representation of selected value', + origin: VariableOrigin.Value, + }, + { + value: `${DataLinkBuiltInVars.valueRaw}`, + label: 'Raw', + documentation: 'Raw value', + origin: VariableOrigin.Value, }, ]; -export const getCalculationValueDataLinksVariableSuggestions = (): VariableSuggestion[] => [ - ...getPanelLinksVariableSuggestions(), +const buildLabelPath = (label: string) => { + return label.indexOf('.') > -1 ? `["${label}"]` : `.${label}`; +}; + +export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [ + ...templateSrv.variables.map(variable => ({ + value: variable.name as string, + label: variable.name, + origin: VariableOrigin.Template, + })), { - value: `${DataLinkBuiltInVars.seriesName}`, - documentation: 'Adds series name', - origin: VariableOrigin.BuiltIn, + value: `${DataLinkBuiltInVars.includeVars}`, + label: 'All variables', + documentation: 'Adds current variables', + origin: VariableOrigin.Template, }, + ...timeRangeVars, ]; +const getSeriesVars = (dataFrames: DataFrame[]) => { + const labels = _.flatten(dataFrames.map(df => Object.keys(df.labels || {}))); + + return [ + { + value: `${DataLinkBuiltInVars.seriesName}`, + label: 'Name', + documentation: 'Name of the series', + origin: VariableOrigin.Series, + }, + ...labels.map(label => ({ + value: `__series.labels${buildLabelPath(label)}`, + label: `labels.${label}`, + documentation: `${label} label value`, + origin: VariableOrigin.Series, + })), + ]; +}; +export const getDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => { + const seriesVars = getSeriesVars(dataFrames); + const valueTimeVar = { + value: `${DataLinkBuiltInVars.valueTime}`, + label: 'Time', + documentation: 'Time value of the clicked datapoint (in ms epoch)', + origin: VariableOrigin.Value, + }; + + return [...seriesVars, ...fieldVars, ...valueVars, valueTimeVar, ...getPanelLinksVariableSuggestions()]; +}; + +export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => { + const seriesVars = getSeriesVars(dataFrames); + const valueCalcVar = { + value: `${DataLinkBuiltInVars.valueCalc}`, + label: 'Calculation name', + documentation: 'Name of the calculation the value is a result of', + origin: VariableOrigin.Value, + }; + return [...seriesVars, ...fieldVars, ...valueVars, valueCalcVar, ...getPanelLinksVariableSuggestions()]; +}; + export interface LinkService { getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel; } @@ -83,16 +154,15 @@ export class LinkSrv implements LinkService { const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl()); const info: LinkModel = { - href: link.url, + href: link.url.replace(/\s|\n/g, ''), title: this.templateSrv.replace(link.title || '', scopedVars), target: link.targetBlank ? '_blank' : '_self', origin, }; - this.templateSrv.fillVariableValuesForUrl(params, scopedVars); const variablesQuery = toUrlParams(params); - info.href = this.templateSrv.replace(link.url, { + info.href = this.templateSrv.replace(info.href, { ...scopedVars, [DataLinkBuiltInVars.keepTime]: { text: timeRangeUrl, diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index 3320d3aa128f..6e3daaf4a657 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -105,11 +105,13 @@ describe('linkSrv', () => { linkSrv.getDataLinkUIModel( { title: 'Any title', - url: `/d/1?var-test=$${DataLinkBuiltInVars.seriesName}`, + url: `/d/1?var-test=$\{${DataLinkBuiltInVars.seriesName}}`, }, { - [DataLinkBuiltInVars.seriesName]: { - value: 'A-series', + __series: { + value: { + name: 'A-series', + }, text: 'A-series', }, }, @@ -122,12 +124,12 @@ describe('linkSrv', () => { linkSrv.getDataLinkUIModel( { title: 'Any title', - url: `/d/1?time=$${DataLinkBuiltInVars.valueTime}`, + url: `/d/1?time=$\{${DataLinkBuiltInVars.valueTime}}`, }, { - [DataLinkBuiltInVars.valueTime]: { - value: dataPointMock.datapoint[0], - text: dataPointMock.datapoint[0], + __value: { + value: { time: dataPointMock.datapoint[0] }, + text: 'Value', }, }, {} diff --git a/public/app/features/templating/specs/template_srv.test.ts b/public/app/features/templating/specs/template_srv.test.ts index 8f5c5b72212f..4d2f4accd6c1 100644 --- a/public/app/features/templating/specs/template_srv.test.ts +++ b/public/app/features/templating/specs/template_srv.test.ts @@ -24,6 +24,27 @@ describe('templateSrv', () => { initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); + it('scoped vars should support objects', () => { + const target = _templateSrv.replace('${series.name} ${series.nested.field}', { + series: { value: { name: 'Server1', nested: { field: 'nested' } } }, + }); + expect(target).toBe('Server1 nested'); + }); + + it('scoped vars should support objects with propert names with dot', () => { + const target = _templateSrv.replace('${series.name} ${series.nested["field.with.dot"]}', { + series: { value: { name: 'Server1', nested: { 'field.with.dot': 'nested' } } }, + }); + expect(target).toBe('Server1 nested'); + }); + + it('scoped vars should support arrays of objects', () => { + const target = _templateSrv.replace('${series.rows[0].name} ${series.rows[1].name}', { + series: { value: { rows: [{ name: 'first' }, { name: 'second' }] } }, + }); + expect(target).toBe('first second'); + }); + it('should replace $test with scoped value', () => { const target = _templateSrv.replace('this.$test.filters', { test: { value: 'mupp', text: 'asd' }, diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index ab358bf8fd41..42358aa5f0d5 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -7,6 +7,10 @@ function luceneEscape(value: string) { return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1'); } +interface FieldAccessorCache { + [key: string]: (obj: any) => any; +} + export class TemplateSrv { variables: any[]; @@ -15,6 +19,7 @@ export class TemplateSrv { private grafanaVariables: any = {}; private builtIns: any = {}; private timeRange: TimeRange = null; + private fieldAccessorCache: FieldAccessorCache = {}; constructor() { this.builtIns['__interval'] = { text: '1s', value: '1s' }; @@ -224,21 +229,44 @@ export class TemplateSrv { return values; } + getFieldAccessor(fieldPath: string) { + const accessor = this.fieldAccessorCache[fieldPath]; + if (accessor) { + return accessor; + } + + return (this.fieldAccessorCache[fieldPath] = _.property(fieldPath)); + } + + getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars) { + const scopedVar = scopedVars[variableName]; + if (!scopedVar) { + return null; + } + + if (fieldPath) { + return this.getFieldAccessor(fieldPath)(scopedVar.value); + } + + return scopedVar.value; + } + replace(target: string, scopedVars?: ScopedVars, format?: string | Function): any { if (!target) { return target; } - let variable, systemValue, value, fmt; this.regex.lastIndex = 0; - return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => { - variable = this.index[var1 || var2 || var3]; - fmt = fmt2 || fmt3 || format; + return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const variableName = var1 || var2 || var3; + const variable = this.index[variableName]; + const fmt = fmt2 || fmt3 || format; + if (scopedVars) { - value = scopedVars[var1 || var2 || var3]; - if (value) { - return this.formatValue(value.value, fmt, variable); + const value = this.getVariableValue(variableName, fieldPath, scopedVars); + if (value !== null && value !== undefined) { + return this.formatValue(value, fmt, variable); } } @@ -246,12 +274,12 @@ export class TemplateSrv { return match; } - systemValue = this.grafanaVariables[variable.current.value]; + const systemValue = this.grafanaVariables[variable.current.value]; if (systemValue) { return this.formatValue(systemValue, fmt, variable); } - value = variable.current.value; + let value = variable.current.value; if (this.isAllValue(value)) { value = this.getAllValue(variable); // skip formatting of custom all values diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index d4cfcffcdd16..7010e548132f 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -7,7 +7,7 @@ import { assignModelProperties } from 'app/core/utils/model_utils'; * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} */ -export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g; +export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::(\w+))?}/g; // Helper function since lastIndex is not reset export const variableRegexExec = (variableString: string) => { diff --git a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx index 96a72e253b87..31fcecbc9716 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx @@ -68,8 +68,8 @@ export class BarGaugePanelEditor extends PureComponent diff --git a/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts b/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts index 0aa1b3c761b9..84f44aa1e57f 100644 --- a/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts +++ b/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts @@ -12,7 +12,7 @@ export interface FlotDataPoint { export class GraphContextMenuCtrl { private source?: FlotDataPoint | null; private scope?: any; - menuItems: ContextMenuItem[]; + menuItemsSupplier?: () => ContextMenuItem[]; scrollContextElement: HTMLElement | null; position: { x: number; @@ -23,7 +23,6 @@ export class GraphContextMenuCtrl { constructor($scope: any) { this.isVisible = false; - this.menuItems = []; this.scope = $scope; } @@ -70,11 +69,7 @@ export class GraphContextMenuCtrl { return this.source; }; - setMenuItems = (items: ContextMenuItem[]) => { - this.menuItems = items; - }; - - getMenuItems = () => { - return this.menuItems; + setMenuItemsSupplier = (menuItemsSupplier: () => ContextMenuItem[]) => { + this.menuItemsSupplier = menuItemsSupplier; }; } diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index ed5b965b8f2c..c691bb8b5423 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -16,7 +16,6 @@ import GraphTooltip from './graph_tooltip'; import { ThresholdManager } from './threshold_manager'; import { TimeRegionManager } from './time_region_manager'; import { EventManager } from 'app/features/annotations/all'; -import { LinkService, LinkSrv } from 'app/features/panel/panellinks/link_srv'; import { convertToHistogramData } from './histogram'; import { alignYLevel } from './align_yaxes'; import config from 'app/core/config'; @@ -25,12 +24,13 @@ import ReactDOM from 'react-dom'; import { GraphLegendProps, Legend } from './Legend/Legend'; import { GraphCtrl } from './module'; -import { getValueFormat, ContextMenuItem, ContextMenuGroup, DataLinkBuiltInVars } from '@grafana/ui'; -import { provideTheme } from 'app/core/utils/ConfigProvider'; -import { DataLink, toUtc } from '@grafana/data'; -import { GraphContextMenuCtrl, FlotDataPoint } from './GraphContextMenuCtrl'; +import { getValueFormat, ContextMenuGroup, FieldDisplay, ContextMenuItem, getDisplayProcessor } from '@grafana/ui'; +import { provideTheme, getCurrentTheme } from 'app/core/utils/ConfigProvider'; +import { toUtc, LinkModelSupplier, DataFrameView } from '@grafana/data'; +import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { ContextSrv } from 'app/core/services/context_srv'; +import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; const LegendWithThemeProvider = provideTheme(Legend); @@ -50,7 +50,7 @@ class GraphElement { timeRegionManager: TimeRegionManager; legendElem: HTMLElement; - constructor(private scope: any, private elem: JQuery, private timeSrv: TimeSrv, private linkSrv: LinkService) { + constructor(private scope: any, private elem: JQuery, private timeSrv: TimeSrv) { this.ctrl = scope.ctrl; this.contextMenu = scope.ctrl.contextMenuCtrl; this.dashboard = this.ctrl.dashboard; @@ -175,53 +175,48 @@ class GraphElement { } } - getContextMenuItems = (flotPosition: { x: number; y: number }, item?: FlotDataPoint): ContextMenuGroup[] => { - const dataLinks: DataLink[] = this.panel.options.dataLinks || []; - - const items: ContextMenuGroup[] = [ - { - items: [ - { - label: 'Add annotation', - icon: 'gicon gicon-annotation', - onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }), - }, - ], - }, - ]; - - return item - ? [ - ...items, - { - items: [ - ...dataLinks.map(link => { - const linkUiModel = this.linkSrv.getDataLinkUIModel( - link, - { - ...this.panel.scopedVars, - [DataLinkBuiltInVars.seriesName]: { value: item.series.alias, text: item.series.alias }, - [DataLinkBuiltInVars.valueTime]: { value: item.datapoint[0], text: item.datapoint[0] }, - }, - item - ); - return { - label: linkUiModel.title, - url: linkUiModel.href, - target: linkUiModel.target, - icon: `fa ${linkUiModel.target === '_self' ? 'fa-link' : 'fa-external-link'}`, - }; - }), - ], - }, - ] - : items; + getContextMenuItemsSupplier = ( + flotPosition: { x: number; y: number }, + linksSupplier?: LinkModelSupplier + ): (() => ContextMenuGroup[]) => { + return () => { + // Fixed context menu items + const items: ContextMenuGroup[] = [ + { + items: [ + { + label: 'Add annotation', + icon: 'gicon gicon-annotation', + onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }), + }, + ], + }, + ]; + + if (!linksSupplier) { + return items; + } + + const dataLinks = [ + { + items: linksSupplier.getLinks(this.panel.scopedVars).map(link => { + return { + label: link.title, + url: link.href, + target: link.target, + icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-link'}`, + }; + }), + }, + ]; + + return [...items, ...dataLinks]; + }; }; onPlotClick(event: JQueryEventObject, pos: any, item: any) { const scrollContextElement = this.elem.closest('.view') ? this.elem.closest('.view').get()[0] : null; const contextMenuSourceItem = item; - let contextMenuItems: ContextMenuItem[]; if (this.panel.xaxis.mode !== 'time') { // Skip if panel in histogram or series mode @@ -239,12 +234,40 @@ class GraphElement { return; } else { this.tooltip.clear(this.plot); - contextMenuItems = this.getContextMenuItems(pos, item) as ContextMenuItem[]; + let linksSupplier: LinkModelSupplier; + + if (item) { + // pickup y-axis index to know which field's config to apply + const yAxisConfig = this.panel.yaxes[item.series.yaxis.n === 2 ? 1 : 0]; + const fieldConfig = { + decimals: yAxisConfig.decimals, + links: this.panel.options.dataLinks || [], + }; + const dataFrame = this.ctrl.dataList[item.series.dataFrameIndex]; + const field = dataFrame.fields[item.series.fieldIndex]; + + const fieldDisplay = getDisplayProcessor({ + config: fieldConfig, + theme: getCurrentTheme(), + })(field.values.get(item.dataIndex)); + + linksSupplier = this.panel.options.dataLinks + ? getFieldLinksSupplier({ + display: fieldDisplay, + name: field.name, + view: new DataFrameView(dataFrame), + rowIndex: item.dataIndex, + colIndex: item.series.fieldIndex, + field: fieldConfig, + }) + : undefined; + } + this.scope.$apply(() => { // Setting nearest CustomScrollbar element as a scroll context for graph context menu this.contextMenu.setScrollContextElement(scrollContextElement); this.contextMenu.setSource(contextMenuSourceItem); - this.contextMenu.setMenuItems(contextMenuItems); + this.contextMenu.setMenuItemsSupplier(this.getContextMenuItemsSupplier(pos, linksSupplier) as any); this.contextMenu.toggleMenu(pos); }); } @@ -363,7 +386,6 @@ class GraphElement { this.thresholdManager.addFlotOptions(options, this.panel); this.timeRegionManager.addFlotOptions(options, this.panel); this.eventManager.addFlotEvents(this.annotations, options); - this.sortedSeries = this.sortSeries(this.data, this.panel); this.callPlot(options, true); } @@ -855,12 +877,12 @@ class GraphElement { } /** @ngInject */ -function graphDirective(timeSrv: TimeSrv, popoverSrv: any, contextSrv: ContextSrv, linkSrv: LinkSrv) { +function graphDirective(timeSrv: TimeSrv, popoverSrv: any, contextSrv: ContextSrv) { return { restrict: 'A', template: '', link: (scope: any, elem: JQuery) => { - return new GraphElement(scope, elem, timeSrv, linkSrv); + return new GraphElement(scope, elem, timeSrv); }, }; } diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 3e1bbc6dc12e..0992bed53524 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -36,7 +36,7 @@ class GraphCtrl extends MetricsPanelCtrl { subTabIndex: number; processor: DataProcessor; contextMenuCtrl: GraphContextMenuCtrl; - linkVariableSuggestions: VariableSuggestion[] = getDataLinksVariableSuggestions(); + linkVariableSuggestions: VariableSuggestion[] = []; panelDefaults: any = { // datasource name, null = default datasource @@ -216,6 +216,8 @@ class GraphCtrl extends MetricsPanelCtrl { range: this.range, }); + this.linkVariableSuggestions = getDataLinksVariableSuggestions(data); + this.dataWarning = null; const datapointsCount = this.seriesList.reduce((prev, series) => { return prev + series.datapoints.length; @@ -337,6 +339,10 @@ class GraphCtrl extends MetricsPanelCtrl { formatDate = (date: DateTimeInput, format?: string) => { return this.dashboard.formatDate.apply(this.dashboard, [date, format]); }; + + getDataFrameByRefId = (refId: string) => { + return this.dataList.filter(dataFrame => dataFrame.refId === refId)[0]; + }; } export { GraphCtrl, GraphCtrl as PanelCtrl }; diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index 555fbdf88532..563d564df50b 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -121,7 +121,7 @@ describe('grafanaGraph', () => { $.plot = ctrl.plot = jest.fn(); scope.ctrl = ctrl; - link = graphDirective({} as any, {}, {} as any, {} as any).link(scope, { + link = graphDirective({} as any, {}, {} as any).link(scope, { width: () => 500, mouseleave: () => {}, bind: () => {}, diff --git a/public/app/plugins/panel/graph/template.ts b/public/app/plugins/panel/graph/template.ts index 462e0a0d6451..e1ddde2a34d8 100644 --- a/public/app/plugins/panel/graph/template.ts +++ b/public/app/plugins/panel/graph/template.ts @@ -8,7 +8,7 @@ const template = `
- + diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 8e93c38649a5..ca9c17f97171 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -238,7 +238,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { } const processor = getDisplayProcessor({ - field: { + config: { ...fieldInfo.field.config, unit: panel.format, decimals: panel.decimals, diff --git a/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx b/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx index 68b0b1c2c03f..4b5006d02a8e 100644 --- a/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx +++ b/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx @@ -70,8 +70,8 @@ export class SingleStatEditor extends PureComponent From 475673c82feb5aeb9e41375cb75ac2414219b231 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 13 Sep 2019 17:23:58 +0200 Subject: [PATCH 43/87] Docs: Adds a requirements page (#18917) Adds a new requirements page to the installation docs. Wanted/needed some place to document known browser issues and thought that adding this missing page together with OS, hardware, database and browser requirements would be a good thing. Fix: move 6.4 section in upgrade notes further down. Fixes #16487 Ref #18690 --- docs/sources/installation/_index.md | 1 + docs/sources/installation/requirements.md | 75 +++++++++++++++++++++++ docs/sources/installation/upgrading.md | 10 ++- 3 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 docs/sources/installation/requirements.md diff --git a/docs/sources/installation/_index.md b/docs/sources/installation/_index.md index 62736311e6a3..7ffee41baccd 100644 --- a/docs/sources/installation/_index.md +++ b/docs/sources/installation/_index.md @@ -12,6 +12,7 @@ weight = 1 ## Installing Grafana +- [Requirements](requirements) - [Installing on Debian / Ubuntu](debian) - [Installing on RPM-based Linux (CentOS, Fedora, OpenSuse, RedHat)](rpm) - [Installing on Mac OS X](mac) diff --git a/docs/sources/installation/requirements.md b/docs/sources/installation/requirements.md new file mode 100644 index 000000000000..bbe448eba9b5 --- /dev/null +++ b/docs/sources/installation/requirements.md @@ -0,0 +1,75 @@ ++++ +title = "Requirements" +description = "Requirements for Grafana" +keywords = ["grafana", "installation", "documentation"] +type = "docs" +[menu.docs] +name = "Requirements" +identifier = "requirements" +parent = "installation" +weight = -1 ++++ + +# Requirements + +This page includes useful information on the supported Operating Systems as well as the hardware requirements that are needed to install and use Grafana. + +## Operating Systems + +### Supported + +- [Debian / Ubuntu](/installation/debian) +- [RPM-based Linux (CentOS, Fedora, OpenSuse, RedHat)](/installation/rpm) +- [Mac OS X](/installation/mac) +- [Windows](/installation/windows) + +### Unsupported + +Installation of Grafana on other operating systems is possible, but not supported. Please see the [building from source](/project/building_from_source/#building-grafana-from-source) guide for more information. + +## Hardware requirements + +Grafana does not use a lot of resources and is very lightweight in use of memory and CPU. Minimum recommendation is 255mb of memory and 1 CPU. + +Depending on what features are being used and to what extent the requirements varies. Features that consume and requires more resources: + +- Server side rendering of images +- [Alerting](/alerting/rules/) +- Data source proxy + +## Database + +Grafana requires a database to store its configuration data, e.g. users, data sources and dashboards. The exact requirements depend on the size of the Grafana installation (e.g. the number of users, data sources, dashboards, features in use etc). + +Grafana supports the following databases: + +- SQLite +- MySQL +- PostgreSQL + +Per default Grafana ships with and uses SQLite which is an embedded database stored on disk in Grafana's installation location. + +## Supported web browsers + +Grafana is supported in the following browsers: + +- Chrome/Chromium +- Firefox +- Safari +- Microsoft Edge + +> Note 1: Older versions of above browsers may not be supported + +> Note 2: Internet Explorer 11 is only fully supported in Grafana versions prior v6.0. + +> Note 3: Running Grafana without JavaScript enabled in the browser is not supported + +### Known issues + +#### Problem with logging in using Safari 12 + +There is a known [iOS Safari 12 issue](https://bugs.webkit.org/show_bug.cgi?id=188165) that prevents the Grafana session cookie from being written after a successful login. +A quick workaround for this problem would be to configure [cookie_samesite](/installation/configuration/#cookie-samesite) to `none`. However, there is another known [Safari 12 issue](https://bugs.webkit.org/show_bug.cgi?id=198181) that threats `SameSite=none` as `strict` which also +prevents the Grafana session cookie from being written after a successful login. + +To resolve using `none` as `SameSite` cookie attribute in combination with Safari 12, please upgrade to at least Grafana v6.3.3 which includes a fix. diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index c1c91f031ab8..8130182aa070 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -135,11 +135,6 @@ If you have text panels with script tags they will no longer work due to a new s Read more [here](/installation/configuration/#disable-sanitize-html) about this new setting. -## Upgrading to v6.4 - -One of the database migrations included in this release will merge multiple rows used to represent an annotation range into a single row. If you have a large number of region annotations the database migration may take a long time to complete. See [Upgrading to v5.2](#upgrading-to-v5-2) for tips on how to manage this process. - - ### Authentication and security If your using Grafana's builtin, LDAP (without Auth Proxy) or OAuth authentication all users will be required to login upon the next visit after the upgrade. @@ -166,7 +161,6 @@ login_maximum_lifetime_days = 1 The default cookie name for storing the auth token is `grafana_session`. you can configure this with `login_cookie_name` in `[auth]` settings. - ## Upgrading to v6.2 ### Ensure encryption of datasource secrets @@ -199,3 +193,7 @@ The semantics of `max concurrent shard requests` changed in Elasticsearch v7.0, If you upgrade Elasticsearch to v7.0+ you should make sure to update the datasource configuration in Grafana so that version is `7.0+` and `max concurrent shard requests` properly configured. 256 was the default in pre v7.0 versions. In v7.0 and above 5 is the default. + +## Upgrading to v6.4 + +One of the database migrations included in this release will merge multiple rows used to represent an annotation range into a single row. If you have a large number of region annotations the database migration may take a long time to complete. See [Upgrading to v5.2](#upgrading-to-v5-2) for tips on how to manage this process. From 7b7b95341e73d0ac173e9ccef25cc129048b8701 Mon Sep 17 00:00:00 2001 From: gotjosh Date: Fri, 13 Sep 2019 16:26:25 +0100 Subject: [PATCH 44/87] LDAP: Allow an user to be synchronised against LDAP (#18976) * LDAP: Allow an user to be synchronised against LDAP This PR introduces the /ldap/sync/:id endpoint. It allows a user to be synchronized against LDAP on demand. A few things to note are: LDAP needs to be enabled for the sync to work It only works against users that originally authenticated against LDAP If the user is the Grafana admin and it needs to be disabled - it will not sync the information Includes a tiny refactor that favours the JSONEq assertion helper instead of manually parsing JSON strings. --- pkg/api/api.go | 1 + pkg/api/ldap_debug.go | 108 +++++++++++++++-- pkg/api/ldap_debug_test.go | 240 ++++++++++++++++++++++++++++++++----- pkg/api/login_test.go | 13 -- pkg/login/ldap_login.go | 6 +- 5 files changed, 312 insertions(+), 56 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 730efda4a98e..6a590e28cfb0 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -395,6 +395,7 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/provisioning/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources)) adminRoute.Post("/provisioning/notifications/reload", Wrap(hs.AdminProvisioningReloadNotifications)) adminRoute.Post("/ldap/reload", Wrap(hs.ReloadLDAPCfg)) + adminRoute.Post("/ldap/sync/:id", Wrap(hs.PostSyncUserWithLDAP)) adminRoute.Get("/ldap/:username", Wrap(hs.GetUserFromLDAP)) adminRoute.Get("/ldap/status", Wrap(hs.GetLDAPStatus)) }, reqGrafanaAdmin) diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index 22252622e848..7ccbb8fbecac 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -1,20 +1,24 @@ package api import ( + "context" "fmt" "net/http" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/multildap" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) var ( getLDAPConfig = multildap.GetConfig newLDAP = multildap.New + tokenService = AuthToken{}.TokenService logger = log.New("LDAP.debug") @@ -49,6 +53,22 @@ type LDAPUserDTO struct { Teams []models.TeamOrgGroupDTO `json:"teams"` } +// LDAPServerDTO is a serializer for LDAP server statuses +type LDAPServerDTO struct { + Host string `json:"host"` + Port int `json:"port"` + Available bool `json:"available"` + Error string `json:"error"` +} + +type AuthToken struct { + TokenService TokenRevoker `inject:""` +} + +type TokenRevoker interface { + RevokeAllUserTokens(context.Context, int64) error +} + // FetchOrgs fetches the organization(s) information by executing a single query to the database. Then, populating the DTO with the information retrieved. func (user *LDAPUserDTO) FetchOrgs() error { orgIds := []int64{} @@ -82,14 +102,6 @@ func (user *LDAPUserDTO) FetchOrgs() error { return nil } -// LDAPServerDTO is a serializer for LDAP server statuses -type LDAPServerDTO struct { - Host string `json:"host"` - Port int `json:"port"` - Available bool `json:"available"` - Error string `json:"error"` -} - // ReloadLDAPCfg reloads the LDAP configuration func (server *HTTPServer) ReloadLDAPCfg() Response { if !ldap.IsEnabled() { @@ -98,7 +110,7 @@ func (server *HTTPServer) ReloadLDAPCfg() Response { err := ldap.ReloadConfig() if err != nil { - return Error(http.StatusInternalServerError, "Failed to reload ldap config.", err) + return Error(http.StatusInternalServerError, "Failed to reload LDAP config", err) } return Success("LDAP config reloaded") } @@ -112,7 +124,7 @@ func (server *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response { ldapConfig, err := getLDAPConfig() if err != nil { - return Error(http.StatusBadRequest, "Failed to obtain the LDAP configuration. Please verify the configuration and try again.", err) + return Error(http.StatusBadRequest, "Failed to obtain the LDAP configuration. Please verify the configuration and try again", err) } ldap := newLDAP(ldapConfig.Servers) @@ -141,6 +153,82 @@ func (server *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response { return JSON(http.StatusOK, serverDTOs) } +// PostSyncUserWithLDAP enables a single Grafana user to be synchronized against LDAP +func (server *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) Response { + if !ldap.IsEnabled() { + return Error(http.StatusBadRequest, "LDAP is not enabled", nil) + } + + ldapConfig, err := getLDAPConfig() + + if err != nil { + return Error(http.StatusBadRequest, "Failed to obtain the LDAP configuration. Please verify the configuration and try again", err) + } + + userId := c.ParamsInt64(":id") + + query := models.GetUserByIdQuery{Id: userId} + + if err := bus.Dispatch(&query); err != nil { // validate the userId exists + if err == models.ErrUserNotFound { + return Error(404, models.ErrUserNotFound.Error(), nil) + } + + return Error(500, "Failed to get user", err) + } + + authModuleQuery := &models.GetAuthInfoQuery{UserId: query.Result.Id, AuthModule: models.AuthModuleLDAP} + + if err := bus.Dispatch(authModuleQuery); err != nil { // validate the userId comes from LDAP + if err == models.ErrUserNotFound { + return Error(404, models.ErrUserNotFound.Error(), nil) + } + + return Error(500, "Failed to get user", err) + } + + ldapServer := newLDAP(ldapConfig.Servers) + user, _, err := ldapServer.User(query.Result.Login) + + if err != nil { + if err == ldap.ErrCouldNotFindUser { // User was not in the LDAP server - we need to take action: + + if setting.AdminUser == query.Result.Login { // User is *the* Grafana Admin. We cannot disable it. + errMsg := fmt.Sprintf(`Refusing to sync grafana super admin "%s" - it would be disabled`, query.Result.Login) + logger.Error(errMsg) + return Error(http.StatusBadRequest, errMsg, err) + } + + // Since the user was not in the LDAP server. Let's disable it. + err := login.DisableExternalUser(query.Result.Login) + + if err != nil { + return Error(http.StatusInternalServerError, "Failed to disable the user", err) + } + + err = tokenService.RevokeAllUserTokens(context.TODO(), userId) + if err != nil { + return Error(http.StatusInternalServerError, "Failed to remove session tokens for the user", err) + } + + return Success("User disabled without any updates in the information") // should this be a success? + } + } + + upsertCmd := &models.UpsertUserCommand{ + ExternalUser: user, + SignupAllowed: setting.LDAPAllowSignup, + } + + err = bus.Dispatch(upsertCmd) + + if err != nil { + return Error(http.StatusInternalServerError, "Failed to udpate the user", err) + } + + return Success("User synced successfully") +} + // GetUserFromLDAP finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced. func (server *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response { if !ldap.IsEnabled() { diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 46bfe57f7c73..938c3b1b2282 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "context" "errors" "net/http" "net/http/httptest" @@ -20,8 +20,12 @@ type LDAPMock struct { Results []*models.ExternalUserInfo } +type TokenServiceMock struct { +} + var userSearchResult *models.ExternalUserInfo var userSearchConfig ldap.ServerConfig +var userSearchError error var pingResult []*multildap.ServerStatus var pingError error @@ -39,7 +43,11 @@ func (m *LDAPMock) Users(logins []string) ([]*models.ExternalUserInfo, error) { } func (m *LDAPMock) User(login string) (*models.ExternalUserInfo, ldap.ServerConfig, error) { - return userSearchResult, userSearchConfig, nil + return userSearchResult, userSearchConfig, userSearchError +} + +func (ts *TokenServiceMock) RevokeAllUserTokens(ctx context.Context, userId int64) error { + return nil } //*** @@ -86,10 +94,7 @@ func TestGetUserFromLDAPApiEndpoint_UserNotFound(t *testing.T) { sc := getUserFromLDAPContext(t, "/api/admin/ldap/user-that-does-not-exist") require.Equal(t, sc.resp.Code, http.StatusNotFound) - responseString, err := getBody(sc.resp) - - assert.Nil(t, err) - assert.Equal(t, "{\"message\":\"No user was found on the LDAP server(s)\"}", responseString) + assert.JSONEq(t, "{\"message\":\"No user was found on the LDAP server(s)\"}", sc.resp.Body.String()) } func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) { @@ -144,19 +149,13 @@ func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) { require.Equal(t, sc.resp.Code, http.StatusBadRequest) - jsonResponse, err := getJSONbody(sc.resp) - assert.Nil(t, err) - expected := ` { "error": "Unable to find organization with ID '2'", "message": "An oganization was not found - Please verify your LDAP configuration" } ` - var expectedJSON interface{} - _ = json.Unmarshal([]byte(expected), &expectedJSON) - - assert.Equal(t, expectedJSON, jsonResponse) + assert.JSONEq(t, expected, sc.resp.Body.String()) } func TestGetUserFromLDAPApiEndpoint(t *testing.T) { @@ -206,9 +205,6 @@ func TestGetUserFromLDAPApiEndpoint(t *testing.T) { require.Equal(t, sc.resp.Code, http.StatusOK) - jsonResponse, err := getJSONbody(sc.resp) - assert.Nil(t, err) - expected := ` { "name": { @@ -231,10 +227,8 @@ func TestGetUserFromLDAPApiEndpoint(t *testing.T) { "teams": null } ` - var expectedJSON interface{} - _ = json.Unmarshal([]byte(expected), &expectedJSON) - assert.Equal(t, expectedJSON, jsonResponse) + assert.JSONEq(t, expected, sc.resp.Body.String()) } func TestGetUserFromLDAPApiEndpoint_WithTeamHandler(t *testing.T) { @@ -289,9 +283,6 @@ func TestGetUserFromLDAPApiEndpoint_WithTeamHandler(t *testing.T) { require.Equal(t, sc.resp.Code, http.StatusOK) - jsonResponse, err := getJSONbody(sc.resp) - assert.Nil(t, err) - expected := ` { "name": { @@ -314,10 +305,8 @@ func TestGetUserFromLDAPApiEndpoint_WithTeamHandler(t *testing.T) { "teams": [] } ` - var expectedJSON interface{} - _ = json.Unmarshal([]byte(expected), &expectedJSON) - assert.Equal(t, expectedJSON, jsonResponse) + assert.JSONEq(t, expected, sc.resp.Body.String()) } //*** @@ -369,8 +358,6 @@ func TestGetLDAPStatusApiEndpoint(t *testing.T) { sc := getLDAPStatusContext(t) require.Equal(t, http.StatusOK, sc.resp.Code) - jsonResponse, err := getJSONbody(sc.resp) - assert.Nil(t, err) expected := ` [ @@ -379,8 +366,201 @@ func TestGetLDAPStatusApiEndpoint(t *testing.T) { { "host": "10.0.0.5", "port": 361, "available": false, "error": "something is awfully wrong" } ] ` - var expectedJSON interface{} - _ = json.Unmarshal([]byte(expected), &expectedJSON) + assert.JSONEq(t, expected, sc.resp.Body.String()) +} + +//*** +// PostSyncUserWithLDAP tests +//*** + +func postSyncUserWithLDAPContext(t *testing.T, requestURL string) *scenarioContext { + t.Helper() + + sc := setupScenarioContext(requestURL) + + ldap := setting.LDAPEnabled + setting.LDAPEnabled = true + defer func() { setting.LDAPEnabled = ldap }() + + hs := &HTTPServer{Cfg: setting.NewCfg()} + + sc.defaultHandler = Wrap(func(c *models.ReqContext) Response { + sc.context = c + return hs.PostSyncUserWithLDAP(c) + }) + + sc.m.Post("/api/admin/ldap/sync/:id", sc.defaultHandler) + + sc.resp = httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, requestURL, nil) + sc.req = req + sc.exec() + + return sc +} + +func TestPostSyncUserWithLDAPAPIEndpoint_Success(t *testing.T) { + getLDAPConfig = func() (*ldap.Config, error) { + return &ldap.Config{}, nil + } + + newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP { + return &LDAPMock{} + } + + userSearchResult = &models.ExternalUserInfo{ + Login: "ldap-daniel", + } + + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + require.Equal(t, "ldap-daniel", cmd.ExternalUser.Login) + return nil + }) + + bus.AddHandler("test", func(q *models.GetUserByIdQuery) error { + require.Equal(t, q.Id, int64(34)) + + q.Result = &models.User{Login: "ldap-daniel", Id: 34} + return nil + }) + + bus.AddHandler("test", func(q *models.GetAuthInfoQuery) error { + require.Equal(t, q.UserId, int64(34)) + require.Equal(t, q.AuthModule, models.AuthModuleLDAP) + + return nil + }) + + sc := postSyncUserWithLDAPContext(t, "/api/admin/ldap/sync/34") + + assert.Equal(t, http.StatusOK, sc.resp.Code) + + expected := ` + { + "message": "User synced successfully" + } + ` + + assert.JSONEq(t, expected, sc.resp.Body.String()) +} + +func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotFound(t *testing.T) { + getLDAPConfig = func() (*ldap.Config, error) { + return &ldap.Config{}, nil + } + + newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP { + return &LDAPMock{} + } + + bus.AddHandler("test", func(q *models.GetUserByIdQuery) error { + require.Equal(t, q.Id, int64(34)) + + return models.ErrUserNotFound + }) + + sc := postSyncUserWithLDAPContext(t, "/api/admin/ldap/sync/34") + + assert.Equal(t, http.StatusNotFound, sc.resp.Code) + + expected := ` + { + "message": "User not found" + } + ` + + assert.JSONEq(t, expected, sc.resp.Body.String()) +} + +func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) { + getLDAPConfig = func() (*ldap.Config, error) { + return &ldap.Config{}, nil + } + + newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP { + return &LDAPMock{} + } + + userSearchError = ldap.ErrCouldNotFindUser + + admin := setting.AdminUser + setting.AdminUser = "ldap-daniel" + defer func() { setting.AdminUser = admin }() + + bus.AddHandler("test", func(q *models.GetUserByIdQuery) error { + require.Equal(t, q.Id, int64(34)) + + q.Result = &models.User{Login: "ldap-daniel", Id: 34} + return nil + }) + + bus.AddHandler("test", func(q *models.GetAuthInfoQuery) error { + require.Equal(t, q.UserId, int64(34)) + require.Equal(t, q.AuthModule, models.AuthModuleLDAP) + + return nil + }) + + sc := postSyncUserWithLDAPContext(t, "/api/admin/ldap/sync/34") + + assert.Equal(t, http.StatusBadRequest, sc.resp.Code) + + expected := ` + { + "error": "Can't find user in LDAP", + "message": "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled" + } + ` + + assert.JSONEq(t, expected, sc.resp.Body.String()) +} + +func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) { + getLDAPConfig = func() (*ldap.Config, error) { + return &ldap.Config{}, nil + } + + tokenService = &TokenServiceMock{} + + newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP { + return &LDAPMock{} + } + + userSearchResult = nil + + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + require.Equal(t, "ldap-daniel", cmd.ExternalUser.Login) + return nil + }) + + bus.AddHandler("test", func(q *models.GetUserByIdQuery) error { + require.Equal(t, q.Id, int64(34)) + + q.Result = &models.User{Login: "ldap-daniel", Id: 34} + return nil + }) + + bus.AddHandler("test", func(q *models.GetExternalUserInfoByLoginQuery) error { + assert.Equal(t, "ldap-daniel", q.LoginOrEmail) + q.Result = &models.ExternalUserInfo{IsDisabled: true, UserId: 34} + + return nil + }) + + bus.AddHandler("test", func(cmd *models.DisableUserCommand) error { + assert.Equal(t, 34, cmd.UserId) + return nil + }) + + sc := postSyncUserWithLDAPContext(t, "/api/admin/ldap/sync/34") + + assert.Equal(t, http.StatusOK, sc.resp.Code) + + expected := ` + { + "message": "User disabled without any updates in the information" + } + ` - assert.Equal(t, expectedJSON, jsonResponse) + assert.JSONEq(t, expected, sc.resp.Body.String()) } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 0d0983f433bc..fd27bd3f656b 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -2,7 +2,6 @@ package api import ( "encoding/hex" - "encoding/json" "errors" "io/ioutil" "net/http" @@ -52,18 +51,6 @@ func getBody(resp *httptest.ResponseRecorder) (string, error) { return string(responseData), nil } -func getJSONbody(resp *httptest.ResponseRecorder) (interface{}, error) { - var j interface{} - - err := json.Unmarshal(resp.Body.Bytes(), &j) - - if err != nil { - return nil, err - } - - return j, nil -} - func TestLoginErrorCookieApiEndpoint(t *testing.T) { mockSetIndexViewData() defer resetSetIndexViewData() diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go index daf0dfe93f6d..3077b21432a7 100644 --- a/pkg/login/ldap_login.go +++ b/pkg/login/ldap_login.go @@ -40,7 +40,7 @@ var loginUsingLDAP = func(query *models.LoginUserQuery) (bool, error) { if err != nil { if err == ldap.ErrCouldNotFindUser { // Ignore the error since user might not be present anyway - disableExternalUser(query.Username) + DisableExternalUser(query.Username) return true, ldap.ErrInvalidCredentials } @@ -61,8 +61,8 @@ var loginUsingLDAP = func(query *models.LoginUserQuery) (bool, error) { return true, nil } -// disableExternalUser marks external user as disabled in Grafana db -func disableExternalUser(username string) error { +// DisableExternalUser marks external user as disabled in Grafana db +func DisableExternalUser(username string) error { // Check if external user exist in Grafana userQuery := &models.GetExternalUserInfoByLoginQuery{ LoginOrEmail: username, From 2acffbeb10174c97bf6e3a7729162eda7cd84f44 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 13 Sep 2019 18:12:52 +0200 Subject: [PATCH 45/87] CLI: Fix installing plugins on windows (#19061) Fixes #19022 --- .../grafana-cli/commands/install_command.go | 10 +++------ .../commands/install_command_test.go | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index f2ba500a953a..0301b29ede3a 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -199,14 +199,14 @@ func extractFiles(body []byte, pluginName string, filePath string, allowSymlinks } for _, zf := range r.File { newFileName := RemoveGitBuildFromName(pluginName, zf.Name) - if !isPathSafe(newFileName, path.Join(filePath, pluginName)) { + if !isPathSafe(newFileName, filepath.Join(filePath, pluginName)) { return xerrors.Errorf("filepath: %v tries to write outside of plugin directory: %v. This can be a security risk.", zf.Name, path.Join(filePath, pluginName)) } newFile := path.Join(filePath, newFileName) if zf.FileInfo().IsDir() { err := os.Mkdir(newFile, 0755) - if permissionsError(err) { + if os.IsPermission(err) { return fmt.Errorf(permissionsDeniedMessage, newFile) } } else { @@ -234,10 +234,6 @@ func extractFiles(body []byte, pluginName string, filePath string, allowSymlinks return nil } -func permissionsError(err error) bool { - return err != nil && strings.Contains(err.Error(), "permission denied") -} - func isSymlink(file *zip.File) bool { return file.Mode()&os.ModeSymlink == os.ModeSymlink } @@ -269,7 +265,7 @@ func extractFile(file *zip.File, filePath string) (err error) { dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { - if permissionsError(err) { + if os.IsPermission(err) { return xerrors.Errorf(permissionsDeniedMessage, filePath) } return errutil.Wrap("Failed to open file", err) diff --git a/pkg/cmd/grafana-cli/commands/install_command_test.go b/pkg/cmd/grafana-cli/commands/install_command_test.go index 5babecb8fe71..f9b45ed1d2d2 100644 --- a/pkg/cmd/grafana-cli/commands/install_command_test.go +++ b/pkg/cmd/grafana-cli/commands/install_command_test.go @@ -48,6 +48,7 @@ func TestFoldernameReplacement(t *testing.T) { func TestExtractFiles(t *testing.T) { t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) { + skipWindows(t) pluginDir, del := setupFakePluginsDir(t) defer del() @@ -95,6 +96,7 @@ func TestExtractFiles(t *testing.T) { }) t.Run("Should extract symlinks if allowed", func(t *testing.T) { + skipWindows(t) pluginDir, del := setupFakePluginsDir(t) defer del() @@ -119,16 +121,18 @@ func TestInstallPluginCommand(t *testing.T) { } func TestIsPathSafe(t *testing.T) { + dest := fmt.Sprintf("%stest%spath", string(os.PathSeparator), string(os.PathSeparator)) + t.Run("Should be true on nested destinations", func(t *testing.T) { - assert.True(t, isPathSafe("dest", "/test/path")) - assert.True(t, isPathSafe("dest/one", "/test/path")) - assert.True(t, isPathSafe("../path/dest/one", "/test/path")) + assert.True(t, isPathSafe("dest", dest)) + assert.True(t, isPathSafe("dest/one", dest)) + assert.True(t, isPathSafe("../path/dest/one", dest)) }) t.Run("Should be false on destinations outside of path", func(t *testing.T) { - assert.False(t, isPathSafe("../dest", "/test/path")) - assert.False(t, isPathSafe("../../", "/test/path")) - assert.False(t, isPathSafe("../../test", "/test/path")) + assert.False(t, isPathSafe("../dest", dest)) + assert.False(t, isPathSafe("../../", dest)) + assert.False(t, isPathSafe("../../test", dest)) }) } @@ -189,3 +193,9 @@ func setupFakePluginsDir(t *testing.T) (string, func()) { assert.Nil(t, err) } } + +func skipWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows") + } +} From 10d1cb23baceac39e1ff319f6a15d1c0a8912a1f Mon Sep 17 00:00:00 2001 From: oddlittlebird <52059945+oddlittlebird@users.noreply.github.com> Date: Fri, 13 Sep 2019 09:48:19 -0700 Subject: [PATCH 46/87] Update _index.md (#19045) Added missing alias redirect --- docs/sources/http_api/_index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sources/http_api/_index.md b/docs/sources/http_api/_index.md index 51a72cdbac21..1b5abf0a547e 100644 --- a/docs/sources/http_api/_index.md +++ b/docs/sources/http_api/_index.md @@ -2,6 +2,7 @@ title = "HTTP API" description = "Grafana HTTP API" keywords = ["grafana", "http", "documentation", "api", "overview"] +aliases = ["/http_api/overview"] type = "docs" [menu.docs] name = "HTTP API" From 3d3bbb715911550d89f3f1ce5afbdf2d5194a1ee Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Sun, 15 Sep 2019 12:02:39 +0200 Subject: [PATCH 47/87] Dashboard: Fix arrow positioning in button while in panel edit mode (#19084) --- public/sass/components/_navbar.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/sass/components/_navbar.scss b/public/sass/components/_navbar.scss index 128a1e624667..b174129173df 100644 --- a/public/sass/components/_navbar.scss +++ b/public/sass/components/_navbar.scss @@ -218,7 +218,8 @@ i.navbar-page-btn__search { i { font-size: $font-size-lg; position: relative; - top: 2px; + top: 1px; + right: 1px; } &:hover { From 382791310595d65af150cec38f5854ae56fc556a Mon Sep 17 00:00:00 2001 From: Lauren Muhlhauser Date: Sun, 15 Sep 2019 06:08:57 -0400 Subject: [PATCH 48/87] NotificationChannels: Add delete button to edit page (#19103) * Add delete button to edit notification channel page * Simplified code --- public/app/features/alerting/NotificationsEditCtrl.ts | 7 +++++++ .../app/features/alerting/partials/notification_edit.html | 1 + 2 files changed, 8 insertions(+) diff --git a/public/app/features/alerting/NotificationsEditCtrl.ts b/public/app/features/alerting/NotificationsEditCtrl.ts index f0f11fce6066..d71c169e557f 100644 --- a/public/app/features/alerting/NotificationsEditCtrl.ts +++ b/public/app/features/alerting/NotificationsEditCtrl.ts @@ -100,6 +100,13 @@ export class AlertNotificationEditCtrl { } } + deleteNotification() { + this.backendSrv.delete(`/api/alert-notifications/${this.model.id}`).then((res: any) => { + this.model = res; + this.$location.path('alerting/notifications'); + }); + } + getNotifierTemplateId(type: string) { return `notifier-options-${type}`; } diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index 176e29f8514b..0135561f9633 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -70,6 +70,7 @@

New Notification Channel

+ Back
From ecb97488d86efe92ae699371ce8a2fdb5806fe3a Mon Sep 17 00:00:00 2001 From: srid12 Date: Sun, 15 Sep 2019 20:59:26 +0530 Subject: [PATCH 49/87] Templating: Clicking Selected should deselect all if 1 or more are already selected (#19104) --- public/app/core/directives/value_select_dropdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/core/directives/value_select_dropdown.ts b/public/app/core/directives/value_select_dropdown.ts index 48861d9f4a03..7675f7e28ec3 100644 --- a/public/app/core/directives/value_select_dropdown.ts +++ b/public/app/core/directives/value_select_dropdown.ts @@ -79,7 +79,7 @@ export class ValueSelectDropdownCtrl { clearSelections() { this.selectedValues = _.filter(this.options, { selected: true }); - if (this.selectedValues.length > 1) { + if (this.selectedValues.length) { _.each(this.options, option => { option.selected = false; }); From c2bd36f550889ef7e9b8dd327d8018e127507d4d Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Sun, 15 Sep 2019 20:20:45 +0200 Subject: [PATCH 50/87] grafana/toolkit: Find module files correctly and add basic error tracing (#19089) * Find correct module files * Add basic error tracing --- .../grafana-toolkit/src/cli/utils/execTask.ts | 2 +- .../src/cli/utils/useSpinner.ts | 1 + .../src/config/webpack.plugin.config.test.ts | 29 +++++++++++++++++++ .../src/config/webpack.plugin.config.ts | 15 ++++------ packages/grafana-toolkit/tslint.json | 3 +- 5 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts diff --git a/packages/grafana-toolkit/src/cli/utils/execTask.ts b/packages/grafana-toolkit/src/cli/utils/execTask.ts index f404206b7a9e..8bbcc4d9dbf0 100644 --- a/packages/grafana-toolkit/src/cli/utils/execTask.ts +++ b/packages/grafana-toolkit/src/cli/utils/execTask.ts @@ -9,7 +9,7 @@ export const execTask = (task: Task) => async (options: TOpt await task.exec(); console.groupEnd(); } catch (e) { - console.log(e); + console.trace(e); process.exit(1); } }; diff --git a/packages/grafana-toolkit/src/cli/utils/useSpinner.ts b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts index b901ed96d9c1..8f0311cc6ee5 100644 --- a/packages/grafana-toolkit/src/cli/utils/useSpinner.ts +++ b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts @@ -10,6 +10,7 @@ export const useSpinner = (spinnerLabel: string, fn: FnToSpin, killProcess await fn(options); spinner.succeed(); } catch (e) { + console.trace(e); spinner.fail(e.message || e); if (killProcess) { process.exit(1); diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts new file mode 100644 index 000000000000..2696d7894f4b --- /dev/null +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts @@ -0,0 +1,29 @@ +import { findModuleFiles } from './webpack.plugin.config'; +const fs = require('fs'); + +jest.mock('fs'); + +const modulePathsMock = [ + 'some/path/module.ts', + 'some/path/module.ts.whatever', + 'some/path/module.tsx', + 'some/path/module.tsx.whatever', + 'some/path/anotherFile.ts', + 'some/path/anotherFile.tsx', +]; + +describe('Plugin webpack config', () => { + describe('findModuleTs', () => { + beforeAll(() => { + fs.statSync.mockReturnValue({ + isDirectory: () => false, + }); + }); + + it('finds module.ts and module.tsx files', () => { + const moduleFiles = findModuleFiles('/', modulePathsMock); + expect(moduleFiles.length).toBe(2); + expect(moduleFiles).toEqual(['/some/path/module.ts', '/some/path/module.tsx']); + }); + }); +}); diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index eb6a5297e551..07b9f350cb82 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -16,7 +16,7 @@ interface WebpackConfigurationOptions { } type WebpackConfigurationGetter = (options: WebpackConfigurationOptions) => webpack.Configuration; -const findModuleTs = (base: string, files?: string[], result?: string[]) => { +export const findModuleFiles = (base: string, files?: string[], result?: string[]) => { files = files || fs.readdirSync(base); result = result || []; @@ -24,9 +24,10 @@ const findModuleTs = (base: string, files?: string[], result?: string[]) => { files.forEach(file => { const newbase = path.join(base, file); if (fs.statSync(newbase).isDirectory()) { - result = findModuleTs(newbase, fs.readdirSync(newbase), result); + result = findModuleFiles(newbase, fs.readdirSync(newbase), result); } else { - if (file.indexOf('module.ts') > -1) { + const filename = path.basename(file); + if (/^module.tsx?$/.exec(filename)) { // @ts-ignore result.push(newbase); } @@ -37,7 +38,7 @@ const findModuleTs = (base: string, files?: string[], result?: string[]) => { }; const getModuleFiles = () => { - return findModuleTs(path.resolve(process.cwd(), 'src')); + return findModuleFiles(path.resolve(process.cwd(), 'src')); }; const getManualChunk = (id: string) => { @@ -206,11 +207,5 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { ], }, optimization, - // optimization: { - // splitChunks: { - // chunks: 'all', - // name: 'shared' - // } - // } }; }; diff --git a/packages/grafana-toolkit/tslint.json b/packages/grafana-toolkit/tslint.json index f51293736244..d058db8cd658 100644 --- a/packages/grafana-toolkit/tslint.json +++ b/packages/grafana-toolkit/tslint.json @@ -1,6 +1,7 @@ { "extends": "../../tslint.json", "rules": { - "import-blacklist": [true, ["^@grafana/runtime.*"]] + "import-blacklist": [true, ["^@grafana/runtime.*"]], + "no-console": [true, "debug", "info", "time", "timeEnd"] } } From b392bba74567fca5bb06fbb21d0f6f8d3f7b5d77 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 16 Sep 2019 07:17:34 +0200 Subject: [PATCH 51/87] Loki: Updated cheat sheet with new filter syntax (#18947) * Loki: Updated cheat sheet with new filter syntax - updated cheat sheet with new filter syntax - added user-specific examples from the user's label set - added link to LogQL docs - separated styles for examples (clickable) and expressions * Review feedback --- packages/grafana-ui/src/types/datasource.ts | 1 + public/app/features/explore/Explore.tsx | 6 +- .../loki/components/LokiCheatSheet.tsx | 140 +++++++++++++----- .../loki/components/LokiStartPage.tsx | 13 -- .../datasource/loki/language_provider.ts | 14 +- public/app/plugins/datasource/loki/module.ts | 4 +- .../prometheus/components/PromCheatSheet.tsx | 7 +- .../prometheus/components/PromStart.tsx | 13 -- .../plugins/datasource/prometheus/module.ts | 4 +- public/sass/pages/_explore.scss | 2 +- 10 files changed, 127 insertions(+), 77 deletions(-) delete mode 100644 public/app/plugins/datasource/loki/components/LokiStartPage.tsx delete mode 100644 public/app/plugins/datasource/prometheus/components/PromStart.tsx diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index 44b60e4f335f..b80543f4e73d 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -299,6 +299,7 @@ export interface ExploreQueryFieldProps< } export interface ExploreStartPageProps { + datasource?: DataSourceApi; onClickExample: (query: DataQuery) => void; } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 96b4f4d6a2bd..3adcd5221d01 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -286,7 +286,11 @@ export class Explore extends React.PureComponent { return (
- {showingStartPage && } + {showingStartPage && ( +
+ +
+ )} {!showingStartPage && ( <> {mode === ExploreMode.Metrics && ( diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx index 35df8959e9df..c6b9b314bc3c 100644 --- a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -1,43 +1,101 @@ -import React from 'react'; - -const CHEAT_SHEET_ITEMS = [ - { - title: 'See your logs', - label: 'Start by selecting a log stream from the Log labels selector.', - }, - { - title: 'Logs from a "job"', - expression: '{job="default/prometheus"}', - label: 'Returns all log lines emitted by instances of this job.', - }, - { - title: 'Combine stream selectors', - expression: '{app="cassandra",namespace="prod"}', - label: 'Returns all log lines from streams that have both labels.', - }, - { - title: 'Search for text', - expression: '{app="cassandra"} (duration|latency)\\s*(=|is|of)\\s*[\\d\\.]+', - label: 'Add a regular expression after the selector to filter for.', - }, -]; - -export default (props: any) => ( -
-

Loki Cheat Sheet

- {CHEAT_SHEET_ITEMS.map(item => ( -
-
{item.title}
- {item.expression && ( -
props.onClickExample({ refId: 'A', expr: item.expression })} - > - {item.expression} +import React, { PureComponent } from 'react'; +import { shuffle } from 'lodash'; +import { ExploreStartPageProps, DataQuery } from '@grafana/ui'; +import LokiLanguageProvider from '../language_provider'; + +const DEFAULT_EXAMPLES = ['{job="default/prometheus"}']; +const PREFERRED_LABELS = ['job', 'app', 'k8s_app']; +const EXAMPLES_LIMIT = 5; + +export default class LokiCheatSheet extends PureComponent { + userLabelTimer: NodeJS.Timeout; + state = { + userExamples: DEFAULT_EXAMPLES, + }; + + componentDidMount() { + this.scheduleUserLabelChecking(); + } + + componentWillUnmount() { + clearTimeout(this.userLabelTimer); + } + + scheduleUserLabelChecking() { + this.userLabelTimer = setTimeout(this.checkUserLabels, 1000); + } + + checkUserLabels = async () => { + // Set example from user labels + const provider: LokiLanguageProvider = this.props.datasource.languageProvider; + if (provider.started) { + const labels = provider.getLabelKeys() || []; + const preferredLabel = PREFERRED_LABELS.find(l => labels.includes(l)); + if (preferredLabel) { + const values = await provider.getLabelValues(preferredLabel); + const userExamples = shuffle(values) + .slice(0, EXAMPLES_LIMIT) + .map(value => `{${preferredLabel}="${value}"}`); + this.setState({ userExamples }); + } + } else { + this.scheduleUserLabelChecking(); + } + }; + + renderExpression(expr: string) { + const { onClickExample } = this.props; + + return ( +
onClickExample({ refId: 'A', expr } as DataQuery)} + > + {expr} +
+ ); + } + + render() { + const { userExamples } = this.state; + + return ( +
+

Loki Cheat Sheet

+
+
See your logs
+
Start by selecting a log stream from the Log labels selector.
+
+ Alternatively, you can write a stream selector into the query field: +
+ {this.renderExpression('{job="default/prometheus"}')} + {userExamples !== DEFAULT_EXAMPLES && userExamples.length > 0 ? ( +
+
Here are some example streams from your logs:
+ {userExamples.map(example => this.renderExpression(example))} +
+ ) : null} +
+
+
Combine stream selectors
+ {this.renderExpression('{app="cassandra",namespace="prod"}')} +
Returns all log lines from streams that have both labels.
+
+ +
+
Filtering for search terms.
+ {this.renderExpression('{app="cassandra"} |~ "(duration|latency)s*(=|is|of)s*[d.]+"')} + {this.renderExpression('{app="cassandra"} |= "exact match"')} + {this.renderExpression('{app="cassandra"} != "do not match"')} +
+ + LogQL + {' '} + supports exact and regular expression filters.
- )} -
{item.label}
+
- ))} -
-); + ); + } +} diff --git a/public/app/plugins/datasource/loki/components/LokiStartPage.tsx b/public/app/plugins/datasource/loki/components/LokiStartPage.tsx deleted file mode 100644 index 62063a790ec3..000000000000 --- a/public/app/plugins/datasource/loki/components/LokiStartPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React, { PureComponent } from 'react'; -import LokiCheatSheet from './LokiCheatSheet'; -import { ExploreStartPageProps } from '@grafana/ui'; - -export default class LokiStartPage extends PureComponent { - render() { - return ( -
- -
- ); - } -} diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 36c5eb11860c..44ba031255f0 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -86,11 +86,23 @@ export default class LokiLanguageProvider extends LanguageProvider { */ start = () => { if (!this.startTask) { - this.startTask = this.fetchLogLabels(this.initialRange); + this.startTask = this.fetchLogLabels(this.initialRange).then(() => { + this.started = true; + return []; + }); } return this.startTask; }; + getLabelKeys(): string[] { + return this.labelKeys[EMPTY_SELECTOR]; + } + + async getLabelValues(key: string): Promise { + await this.fetchLabelValues(key, this.initialRange); + return this.labelValues[EMPTY_SELECTOR][key]; + } + /** * Return suggestions based on input that can be then plugged into a typeahead dropdown. * Keep this DOM-free for testing diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts index b1b5bb463708..652a596e3718 100644 --- a/public/app/plugins/datasource/loki/module.ts +++ b/public/app/plugins/datasource/loki/module.ts @@ -1,6 +1,6 @@ import Datasource from './datasource'; -import LokiStartPage from './components/LokiStartPage'; +import LokiCheatSheet from './components/LokiCheatSheet'; import LokiQueryField from './components/LokiQueryField'; import LokiQueryEditor from './components/LokiQueryEditor'; import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl'; @@ -14,6 +14,6 @@ export { LokiQueryEditor as QueryEditor, LokiConfigCtrl as ConfigCtrl, LokiQueryField as ExploreQueryField, - LokiStartPage as ExploreStartPage, + LokiCheatSheet as ExploreStartPage, LokiAnnotationsQueryCtrl as AnnotationsQueryCtrl, }; diff --git a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx index f3b3dcb7f837..e6aebfb78050 100644 --- a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ExploreStartPageProps, DataQuery } from '@grafana/ui'; const CHEAT_SHEET_ITEMS = [ { @@ -19,15 +20,15 @@ const CHEAT_SHEET_ITEMS = [ }, ]; -export default (props: any) => ( +export default (props: ExploreStartPageProps) => (

PromQL Cheat Sheet

{CHEAT_SHEET_ITEMS.map(item => (
{item.title}
props.onClickExample({ refId: 'A', expr: item.expression })} + className="cheat-sheet-item__example" + onClick={e => props.onClickExample({ refId: 'A', expr: item.expression } as DataQuery)} > {item.expression}
diff --git a/public/app/plugins/datasource/prometheus/components/PromStart.tsx b/public/app/plugins/datasource/prometheus/components/PromStart.tsx deleted file mode 100644 index de545e826e3b..000000000000 --- a/public/app/plugins/datasource/prometheus/components/PromStart.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React, { PureComponent } from 'react'; -import PromCheatSheet from './PromCheatSheet'; -import { ExploreStartPageProps } from '@grafana/ui'; - -export default class PromStart extends PureComponent { - render() { - return ( -
- -
- ); - } -} diff --git a/public/app/plugins/datasource/prometheus/module.ts b/public/app/plugins/datasource/prometheus/module.ts index 0922ce0d3f68..5250868e7ecb 100644 --- a/public/app/plugins/datasource/prometheus/module.ts +++ b/public/app/plugins/datasource/prometheus/module.ts @@ -2,7 +2,7 @@ import { PrometheusDatasource } from './datasource'; import { PromQueryEditor } from './components/PromQueryEditor'; import { PrometheusConfigCtrl } from './config_ctrl'; -import PrometheusStartPage from './components/PromStart'; +import PromCheatSheet from './components/PromCheatSheet'; import PromQueryField from './components/PromQueryField'; class PrometheusAnnotationsQueryCtrl { @@ -15,5 +15,5 @@ export { PrometheusConfigCtrl as ConfigCtrl, PrometheusAnnotationsQueryCtrl as AnnotationsQueryCtrl, PromQueryField as ExploreQueryField, - PrometheusStartPage as ExploreStartPage, + PromCheatSheet as ExploreStartPage, }; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 0018eaa7db35..df924a14bde8 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -345,7 +345,7 @@ font-size: $font-size-h3; } -.cheat-sheet-item__expression { +.cheat-sheet-item__example { margin: $space-xs 0; cursor: pointer; } From 7ace80c71cce5d3e0065590e3c9d2aee1363d3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 16 Sep 2019 09:31:22 +0200 Subject: [PATCH 52/87] Graph: Adds onHorizontalRegionSelected (#19083) * Refactor: Renamed and changed Signature for OnHorizontalRegionSelected * Refactor: Adds onHorizontalRegionSelected to GraphPanelController * Refactor: Moves TimeSrv call to PanelChrome instead --- .../grafana-ui/src/components/Graph/Graph.tsx | 29 ++++++++++++------- .../src/components/Graph/GraphWithLegend.tsx | 7 ++--- packages/grafana-ui/src/types/panel.ts | 3 +- .../dashboard/dashgrid/PanelChrome.tsx | 16 ++++++---- .../features/explore/ExploreGraphPanel.tsx | 6 ++-- .../app/plugins/panel/graph2/GraphPanel.tsx | 13 +++++++-- .../panel/graph2/GraphPanelController.tsx | 11 ++++++- 7 files changed, 56 insertions(+), 29 deletions(-) diff --git a/packages/grafana-ui/src/components/Graph/Graph.tsx b/packages/grafana-ui/src/components/Graph/Graph.tsx index 7db0aa699bc2..942cbc07b61e 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.tsx +++ b/packages/grafana-ui/src/components/Graph/Graph.tsx @@ -2,9 +2,8 @@ import $ from 'jquery'; import React, { PureComponent } from 'react'; import uniqBy from 'lodash/uniqBy'; - // Types -import { TimeRange, GraphSeriesXY, AbsoluteTimeRange, TimeZone, DefaultTimeZone } from '@grafana/data'; +import { TimeRange, GraphSeriesXY, TimeZone, DefaultTimeZone } from '@grafana/data'; export interface GraphProps { series: GraphSeriesXY[]; @@ -17,7 +16,7 @@ export interface GraphProps { height: number; isStacked?: boolean; lineWidth?: number; - onSelectionChanged?: (range: AbsoluteTimeRange) => void; + onHorizontalRegionSelected?: (from: number, to: number) => void; } export class Graph extends PureComponent { @@ -49,12 +48,9 @@ export class Graph extends PureComponent { } onPlotSelected = (event: JQueryEventObject, ranges: { xaxis: { from: number; to: number } }) => { - const { onSelectionChanged } = this.props; - if (onSelectionChanged) { - onSelectionChanged({ - from: ranges.xaxis.from, - to: ranges.xaxis.to, - }); + const { onHorizontalRegionSelected } = this.props; + if (onHorizontalRegionSelected) { + onHorizontalRegionSelected(ranges.xaxis.from, ranges.xaxis.to); } }; @@ -63,7 +59,18 @@ export class Graph extends PureComponent { return; } - const { width, series, timeRange, showLines, showBars, showPoints, isStacked, lineWidth, timeZone } = this.props; + const { + width, + series, + timeRange, + showLines, + showBars, + showPoints, + isStacked, + lineWidth, + timeZone, + onHorizontalRegionSelected, + } = this.props; if (!width) { return; @@ -136,7 +143,7 @@ export class Graph extends PureComponent { labelMarginX: 0, }, selection: { - mode: 'x', + mode: onHorizontalRegionSelected ? 'x' : null, color: '#666', }, }; diff --git a/packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx b/packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx index 61d4f9b071a5..e8e33c3873d1 100644 --- a/packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { css } from 'emotion'; -import { GraphSeriesValue, AbsoluteTimeRange } from '@grafana/data'; +import { GraphSeriesValue } from '@grafana/data'; import { Graph, GraphProps } from './Graph'; import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend'; @@ -22,7 +22,6 @@ export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions { onSeriesAxisToggle?: SeriesAxisToggleHandler; onSeriesToggle?: (label: string, event: React.MouseEvent) => void; onToggleSort: (sortBy: string) => void; - onSelectionChanged?: (range: AbsoluteTimeRange) => void; } const getGraphWithLegendStyles = ({ placement }: GraphWithLegendProps) => ({ @@ -70,7 +69,7 @@ export const GraphWithLegend: React.FunctionComponent = (p hideZero, isStacked, lineWidth, - onSelectionChanged, + onHorizontalRegionSelected, timeZone, } = props; const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props); @@ -104,7 +103,7 @@ export const GraphWithLegend: React.FunctionComponent = (p key={isLegendVisible ? 'legend-visible' : 'legend-invisible'} isStacked={isStacked} lineWidth={lineWidth} - onSelectionChanged={onSelectionChanged} + onHorizontalRegionSelected={onHorizontalRegionSelected} />
diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index 440c55c664ac..3eeab0639660 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,5 +1,5 @@ import { ComponentClass, ComponentType } from 'react'; -import { LoadingState, DataFrame, TimeRange, TimeZone, ScopedVars } from '@grafana/data'; +import { LoadingState, DataFrame, TimeRange, TimeZone, ScopedVars, AbsoluteTimeRange } from '@grafana/data'; import { DataQueryRequest, DataQueryError } from './datasource'; import { PluginMeta, GrafanaPlugin } from './plugin'; @@ -30,6 +30,7 @@ export interface PanelProps { width: number; height: number; replaceVariables: InterpolateFunction; + onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void; } export interface PanelEditorProps { diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index bf3c379f0810..baf8ef2ca0ad 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -2,11 +2,9 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { Unsubscribable } from 'rxjs'; - // Components import { PanelHeader } from './PanelHeader/PanelHeader'; -import { ErrorBoundary } from '@grafana/ui'; - +import { ErrorBoundary, PanelData, PanelPlugin } from '@grafana/ui'; // Utils & Services import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { applyPanelTimeOverrides, calculateInnerPanelHeight } from 'app/features/dashboard/utils/panel'; @@ -14,11 +12,9 @@ import { profiler } from 'app/core/profiler'; import { getProcessedDataFrames } from '../state/runRequest'; import templateSrv from 'app/features/templating/template_srv'; import config from 'app/core/config'; - // Types import { DashboardModel, PanelModel } from '../state'; -import { PanelData, PanelPlugin } from '@grafana/ui'; -import { LoadingState, ScopedVars } from '@grafana/data'; +import { LoadingState, ScopedVars, AbsoluteTimeRange, toUtc } from '@grafana/data'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -219,6 +215,13 @@ export class PanelChrome extends PureComponent { return !(this.props.plugin.meta.skipDataQuery || this.hasPanelSnapshot); } + onChangeTimeRange = (timeRange: AbsoluteTimeRange) => { + this.timeSrv.setTime({ + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }); + }; + renderPanel(width: number, height: number): JSX.Element { const { panel, plugin } = this.props; const { renderCounter, data, isFirstLoad } = this.state; @@ -255,6 +258,7 @@ export class PanelChrome extends PureComponent { renderCounter={renderCounter} replaceVariables={this.replaceVariables} onOptionsChange={this.onOptionsChange} + onChangeTimeRange={this.onChangeTimeRange} />
diff --git a/public/app/features/explore/ExploreGraphPanel.tsx b/public/app/features/explore/ExploreGraphPanel.tsx index 4ff4140e773b..f3ebfd3e9d0f 100644 --- a/public/app/features/explore/ExploreGraphPanel.tsx +++ b/public/app/features/explore/ExploreGraphPanel.tsx @@ -79,9 +79,9 @@ class UnThemedExploreGraphPanel extends PureComponent { } }; - onChangeTime = (absoluteRange: AbsoluteTimeRange) => { + onChangeTime = (from: number, to: number) => { const { onUpdateTimeRange } = this.props; - onUpdateTimeRange(absoluteRange); + onUpdateTimeRange({ from, to }); }; renderGraph = () => { @@ -136,7 +136,7 @@ class UnThemedExploreGraphPanel extends PureComponent { isStacked={isStacked} lineWidth={lineWidth} onSeriesToggle={onSeriesToggle} - onSelectionChanged={this.onChangeTime} + onHorizontalRegionSelected={this.onChangeTime} /> ); }} diff --git a/public/app/plugins/panel/graph2/GraphPanel.tsx b/public/app/plugins/panel/graph2/GraphPanel.tsx index 285a63de1f1f..e18045212010 100644 --- a/public/app/plugins/panel/graph2/GraphPanel.tsx +++ b/public/app/plugins/panel/graph2/GraphPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { PanelProps, GraphWithLegend /*, GraphSeriesXY*/ } from '@grafana/ui'; +import { PanelProps, GraphWithLegend } from '@grafana/ui'; import { Options } from './types'; import { GraphPanelController } from './GraphPanelController'; import { LegendDisplayMode } from '@grafana/ui/src/components/Legend/Legend'; @@ -14,6 +14,7 @@ export const GraphPanel: React.FunctionComponent = ({ height, options, onOptionsChange, + onChangeTimeRange, }) => { if (!data) { return ( @@ -35,8 +36,13 @@ export const GraphPanel: React.FunctionComponent = ({ }; const { asTable, isVisible, ...legendProps } = legendOptions; return ( - - {({ onSeriesToggle, ...controllerApi }) => { + + {({ onSeriesToggle, onHorizontalRegionSelected, ...controllerApi }) => { return ( = ({ sortLegendBy={legendOptions.sortBy} sortLegendDesc={legendOptions.sortDesc} onSeriesToggle={onSeriesToggle} + onHorizontalRegionSelected={onHorizontalRegionSelected} {...graphProps} {...legendProps} {...controllerApi} diff --git a/public/app/plugins/panel/graph2/GraphPanelController.tsx b/public/app/plugins/panel/graph2/GraphPanelController.tsx index eede30143c8e..107dbcac7558 100644 --- a/public/app/plugins/panel/graph2/GraphPanelController.tsx +++ b/public/app/plugins/panel/graph2/GraphPanelController.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { PanelData, GraphSeriesToggler } from '@grafana/ui'; -import { GraphSeriesXY } from '@grafana/data'; +import { GraphSeriesXY, AbsoluteTimeRange } from '@grafana/data'; import { getGraphSeriesModel } from './getGraphSeriesModel'; import { Options, SeriesOptions } from './types'; @@ -12,6 +12,7 @@ interface GraphPanelControllerAPI { onSeriesColorChange: SeriesColorChangeHandler; onSeriesToggle: (label: string, event: React.MouseEvent) => void; onToggleSort: (sortBy: string) => void; + onHorizontalRegionSelected: (from: number, to: number) => void; } interface GraphPanelControllerProps { @@ -19,6 +20,7 @@ interface GraphPanelControllerProps { options: Options; data: PanelData; onOptionsChange: (options: Options) => void; + onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void; } interface GraphPanelControllerState { @@ -32,6 +34,7 @@ export class GraphPanelController extends React.Component From 9d0a076eb1159d56224fa75b37d840b8a31a255e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 16 Sep 2019 12:35:39 +0200 Subject: [PATCH 53/87] Explore: calculate intervals when building data source request (#19107) * Explore: calculate intervals when building data source request * Added unit test * updated unit test --- public/app/core/utils/explore.test.ts | 32 ++++++++++++++++++- public/app/core/utils/explore.ts | 24 +++++++------- public/app/features/explore/state/actions.ts | 8 ++--- public/app/features/explore/state/reducers.ts | 6 +--- .../explore/utils/ResultProcessor.test.ts | 2 +- .../features/explore/utils/ResultProcessor.ts | 6 ++-- public/app/types/explore.ts | 7 +--- 7 files changed, 52 insertions(+), 33 deletions(-) diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 328bf1b5f800..0f21634675dc 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -11,10 +11,11 @@ import { refreshIntervalToSortOrder, SortOrder, sortLogsResult, + buildQueryTransaction, } from './explore'; import { ExploreUrlState, ExploreMode } from 'app/types/explore'; import store from 'app/core/store'; -import { LogsDedupStrategy, LogsModel, LogLevel } from '@grafana/data'; +import { LogsDedupStrategy, LogsModel, LogLevel, dateTime } from '@grafana/data'; import { DataQueryError } from '@grafana/ui'; import { liveOption, offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; @@ -427,4 +428,33 @@ describe('sortLogsResult', () => { }); }); }); + + describe('when buildQueryTransaction', () => { + it('it should calculate interval based on time range', () => { + const queries = [{ refId: 'A' }]; + const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; + const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; + const transaction = buildQueryTransaction(queries, queryOptions, range, false); + + expect(transaction.request.intervalMs).toEqual(60000); + }); + + it('it should calculate interval taking minInterval into account', () => { + const queries = [{ refId: 'A' }]; + const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; + const range = { from: dateTime().subtract(1, 'm'), to: dateTime(), raw: { from: '1h', to: '1h' } }; + const transaction = buildQueryTransaction(queries, queryOptions, range, false); + + expect(transaction.request.intervalMs).toEqual(15000); + }); + + it('it should calculate interval taking maxDataPoints into account', () => { + const queries = [{ refId: 'A' }]; + const queryOptions = { maxDataPoints: 10, minInterval: '15s' }; + const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; + const transaction = buildQueryTransaction(queries, queryOptions, range, false); + + expect(transaction.request.interval).toEqual('2h'); + }); + }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 534c9238db48..38252c7d0e6a 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -13,21 +13,16 @@ import { LogRowModel, LogsModel, LogsDedupStrategy, + IntervalValues, DefaultTimeZone, } from '@grafana/data'; import { renderUrl } from 'app/core/utils/url'; import store from 'app/core/store'; +import kbn from 'app/core/utils/kbn'; import { getNextRefIdChar } from './query'; // Types import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest, PanelModel } from '@grafana/ui'; -import { - ExploreUrlState, - HistoryItem, - QueryTransaction, - QueryIntervals, - QueryOptions, - ExploreMode, -} from 'app/types/explore'; +import { ExploreUrlState, HistoryItem, QueryTransaction, QueryOptions, ExploreMode } from 'app/types/explore'; import { config } from '../config'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -103,17 +98,16 @@ export function buildQueryTransaction( queries: DataQuery[], queryOptions: QueryOptions, range: TimeRange, - queryIntervals: QueryIntervals, scanning: boolean ): QueryTransaction { - const { interval, intervalMs } = queryIntervals; - const configuredQueries = queries.map(query => ({ ...query, ...queryOptions })); const key = queries.reduce((combinedKey, query) => { combinedKey += query.key; return combinedKey; }, ''); + const { interval, intervalMs } = getIntervals(range, queryOptions.minInterval, queryOptions.maxDataPoints); + // Most datasource is using `panelId + query.refId` for cancellation logic. // Using `format` here because it relates to the view panel that the request is for. // However, some datasources don't use `panelId + query.refId`, but only `panelId`. @@ -518,3 +512,11 @@ export const stopQueryState = (querySubscription: Unsubscribable) => { querySubscription.unsubscribe(); } }; + +export function getIntervals(range: TimeRange, lowLimit: string, resolution: number): IntervalValues { + if (!resolution) { + return { interval: '1s', intervalMs: 1000 }; + } + + return kbn.calculateInterval(range, resolution, lowLimit); +} diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index 46ac0a9331ba..cd711839bae1 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -437,7 +437,6 @@ export function runQueries(exploreId: ExploreId): ThunkResult { datasourceError, containerWidth, isLive: live, - queryIntervals, range, scanning, queryResponse, @@ -461,14 +460,13 @@ export function runQueries(exploreId: ExploreId): ThunkResult { // Some datasource's query builders allow per-query interval limits, // but we're using the datasource interval limit for now - const interval = datasourceInstance.interval; + const minInterval = datasourceInstance.interval; stopQueryState(querySubscription); const queryOptions = { - interval, + minInterval, // This is used for logs streaming for buffer size. - // TODO: not sure if this makes sense for normal query when using both graph and table maxDataPoints: mode === ExploreMode.Logs ? 1000 : containerWidth, live, showingGraph, @@ -476,7 +474,7 @@ export function runQueries(exploreId: ExploreId): ThunkResult { }; const datasourceId = datasourceInstance.meta.id; - const transaction = buildQueryTransaction(queries, queryOptions, range, queryIntervals, scanning); + const transaction = buildQueryTransaction(queries, queryOptions, range, scanning); let firstResponse = true; diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 2874b97b892f..27bdd4b6b58f 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -67,9 +67,6 @@ export const DEFAULT_RANGE = { to: 'now', }; -// Millies step for helper bar charts -const DEFAULT_GRAPH_INTERVAL = 15 * 1000; - export const makeInitialUpdateState = (): ExploreUpdateState => ({ datasource: false, queries: false, @@ -93,7 +90,6 @@ export const makeExploreItemState = (): ExploreItemState => ({ history: [], queries: [], initialized: false, - queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL }, range: { from: null, to: null, @@ -617,7 +613,7 @@ export const processQueryResponse = ( } const latency = request.endTime ? request.endTime - request.startTime : 0; - const processor = new ResultProcessor(state, series); + const processor = new ResultProcessor(state, series, request.intervalMs); const graphResult = processor.getGraphResult(); const tableResult = processor.getTableResult(); const logsResult = processor.getLogsResult(); diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 9fa187ea45ea..08fcea79c2bc 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -56,7 +56,7 @@ const testContext = (options: any = {}) => { queryIntervals: { intervalMs: 10 }, } as any) as ExploreItemState; - const resultProcessor = new ResultProcessor(state, combinedOptions.dataFrames); + const resultProcessor = new ResultProcessor(state, combinedOptions.dataFrames, 60000); return { dataFrames: combinedOptions.dataFrames, diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 45fe31cb0103..453fc540cbf2 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/public/app/features/explore/utils/ResultProcessor.ts @@ -7,7 +7,7 @@ import { dataFrameToLogsModel } from 'app/core/logs_model'; import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel'; export class ResultProcessor { - constructor(private state: ExploreItemState, private dataFrames: DataFrame[]) {} + constructor(private state: ExploreItemState, private dataFrames: DataFrame[], private intervalMs: number) {} getGraphResult(): GraphSeriesXY[] { if (this.state.mode !== ExploreMode.Metrics) { @@ -77,9 +77,7 @@ export class ResultProcessor { return null; } - const graphInterval = this.state.queryIntervals.intervalMs; - - const newResults = dataFrameToLogsModel(this.dataFrames, graphInterval); + const newResults = dataFrameToLogsModel(this.dataFrames, this.intervalMs); const sortOrder = refreshIntervalToSortOrder(this.state.refreshInterval); const sortedNewResults = sortLogsResult(newResults, sortOrder); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 37427dd69049..09114c247384 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -186,11 +186,6 @@ export interface ExploreItemState { */ logsResult?: LogsModel; - /** - * Query intervals for graph queries to determine how many datapoints to return. - * Needs to be updated when `datasourceInstance` or `containerWidth` is changed. - */ - queryIntervals: QueryIntervals; /** * Time range for this Explore. Managed by the time picker and used by all query runs. */ @@ -330,7 +325,7 @@ export interface QueryIntervals { } export interface QueryOptions { - interval: string; + minInterval: string; maxDataPoints?: number; live?: boolean; } From e968a2aa86f2862a7f5a8992b57bcf671beaeb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 16 Sep 2019 13:02:26 +0200 Subject: [PATCH 54/87] DataFrame: Fixes to dealing with empty results (#19119) * DataFrame: Fixes to dealing with empty results * review feedback fixes --- .../src/utils/processDataFrame.test.ts | 25 +++++++++++++++++-- .../src/utils/processDataFrame.ts | 10 +++++--- .../explore/utils/ResultProcessor.test.ts | 4 ++- .../features/explore/utils/ResultProcessor.ts | 2 +- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/grafana-data/src/utils/processDataFrame.test.ts b/packages/grafana-data/src/utils/processDataFrame.test.ts index babf7f5ee6c0..a71dff6e9ca0 100644 --- a/packages/grafana-data/src/utils/processDataFrame.test.ts +++ b/packages/grafana-data/src/utils/processDataFrame.test.ts @@ -106,7 +106,7 @@ describe('toDataFrame', () => { }); describe('SerisData backwards compatibility', () => { - it('converts TimeSeries to series and back again', () => { + it('can convert TimeSeries to series and back again', () => { const timeseries = { target: 'Field Name', datapoints: [[100, 1], [200, 2]], @@ -120,6 +120,17 @@ describe('SerisData backwards compatibility', () => { expect(roundtrip.target).toBe(timeseries.target); }); + it('can convert empty table to DataFrame then back to legacy', () => { + const table = { + columns: [], + rows: [], + }; + + const series = toDataFrame(table); + const roundtrip = toLegacyResponseData(series) as TableData; + expect(roundtrip.columns.length).toBe(0); + }); + it('converts TableData to series and back again', () => { const table = { columns: [{ text: 'a', unit: 'ms' }, { text: 'b', unit: 'zz' }, { text: 'c', unit: 'yy' }], @@ -135,7 +146,17 @@ describe('SerisData backwards compatibility', () => { expect(roundtrip).toMatchObject(table); }); - it('converts DataFrame to TableData to series and back again', () => { + it('can convert empty TableData to DataFrame', () => { + const table = { + columns: [], + rows: [], + }; + + const series = toDataFrame(table); + expect(series.fields.length).toBe(0); + }); + + it('can convert DataFrame to TableData to series and back again', () => { const json: DataFrameDTO = { refId: 'Z', meta: { diff --git a/packages/grafana-data/src/utils/processDataFrame.ts b/packages/grafana-data/src/utils/processDataFrame.ts index 542fb950721e..e88bb54ddecc 100644 --- a/packages/grafana-data/src/utils/processDataFrame.ts +++ b/packages/grafana-data/src/utils/processDataFrame.ts @@ -32,12 +32,13 @@ function convertTableToDataFrame(table: TableData): DataFrame { type: FieldType.other, }; }); - // Fill in the field values + for (const row of table.rows) { for (let i = 0; i < fields.length; i++) { fields[i].values.buffer.push(row[i]); } } + for (const f of fields) { const t = guessFieldTypeForField(f); if (t) { @@ -50,7 +51,7 @@ function convertTableToDataFrame(table: TableData): DataFrame { refId: table.refId, meta: table.meta, name: table.name, - length: fields[0].values.length, + length: table.rows.length, }; } @@ -258,9 +259,10 @@ export const toDataFrame = (data: any): DataFrame => { export const toLegacyResponseData = (frame: DataFrame): TimeSeries | TableData => { const { fields } = frame; - const length = fields[0].values.length; + const rowCount = frame.length; const rows: any[][] = []; - for (let i = 0; i < length; i++) { + + for (let i = 0; i < rowCount; i++) { const row: any[] = []; for (let j = 0; j < fields.length; j++) { row.push(fields[j].values.get(i)); diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 08fcea79c2bc..30860f5d7721 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -38,9 +38,11 @@ const testContext = (options: any = {}) => { ], }); + const emptyTable = toDataFrame({ name: 'empty-table', refId: 'A', fields: [] }); + const defaultOptions = { mode: ExploreMode.Metrics, - dataFrames: [timeSeries, table], + dataFrames: [timeSeries, table, emptyTable], graphResult: [] as TimeSeries[], tableResult: new TableModel(), logsResult: { hasUniqueLabels: false, rows: [] as LogRowModel[] }, diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 453fc540cbf2..4e62d44ba22e 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/public/app/features/explore/utils/ResultProcessor.ts @@ -45,7 +45,7 @@ export class ResultProcessor { const tables = onlyTables.map(frame => { const { fields } = frame; const fieldCount = fields.length; - const rowCount = fields[0].values.length; + const rowCount = frame.length; const columns = fields.map(field => ({ text: field.name, From 44a2a648c607119614e3108b1ccd841bc38edf13 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 16 Sep 2019 13:38:03 +0200 Subject: [PATCH 55/87] Login: fix login page failing when navigating from reset password views (#19124) --- pkg/api/frontendsettings.go | 3 +++ pkg/api/login.go | 4 ---- public/app/core/components/Login/LoginCtrl.tsx | 4 +++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 8121ebbe10c6..9919a0aa10e7 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -179,6 +179,9 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf "exploreEnabled": setting.ExploreEnabled, "googleAnalyticsId": setting.GoogleAnalyticsId, "disableLoginForm": setting.DisableLoginForm, + "disableUserSignUp": !setting.AllowUserSignUp, + "loginHint": setting.LoginHint, + "passwordHint": setting.PasswordHint, "externalUserMngInfo": setting.ExternalUserMngInfo, "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkName": setting.ExternalUserMngLinkName, diff --git a/pkg/api/login.go b/pkg/api/login.go index d561c6b3680c..8009e474f4b0 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -40,10 +40,6 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { } viewData.Settings["oauth"] = enabledOAuths - viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp - viewData.Settings["loginHint"] = setting.LoginHint - viewData.Settings["passwordHint"] = setting.PasswordHint - viewData.Settings["disableLoginForm"] = setting.DisableLoginForm viewData.Settings["samlEnabled"] = setting.IsEnterprise && hs.Cfg.SAMLEnabled if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index f8535024c1ad..5f4ae10c4b1e 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -9,7 +9,9 @@ import { getBackendSrv } from '@grafana/runtime'; import { hot } from 'react-hot-loader'; import appEvents from 'app/core/app_events'; -const isOauthEnabled = () => Object.keys(config.oauth).length > 0; +const isOauthEnabled = () => { + return !!config.oauth && Object.keys(config.oauth).length > 0; +}; export interface FormModel { user: string; From a28aefa370853de4166a3fafcea278442d35340e Mon Sep 17 00:00:00 2001 From: David Date: Mon, 16 Sep 2019 13:49:55 +0200 Subject: [PATCH 56/87] Graph: constant series as override (#19102) * Graph: constant series as override - ability to define a series override that takes the first value of a timeseries and produces constant series from it. * Moved constant series override logic to grafana-ui * Added docs for instant queries and constant series override --- .../features/datasources/prometheus.md | 12 ++++++++ .../grafana-ui/src/utils/flotPairs.test.ts | 29 +++++++++++++++++-- packages/grafana-ui/src/utils/flotPairs.ts | 18 +++++++++++- packages/grafana-ui/src/utils/index.ts | 2 +- public/app/plugins/panel/graph/graph.ts | 13 ++++++++- .../panel/graph/series_overrides_ctrl.ts | 2 +- 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index 0a380008307a..5ac3543cb311 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -51,10 +51,22 @@ Open a graph in edit mode by click the title > Edit (or by pressing `e` key whil | _Resolution_ | Controls the step option. Small steps create high-resolution graphs but can be slow over larger time ranges, lowering the resolution can speed things up. `1/2` will try to set step option to generate 1 data point for every other pixel. A value of `1/10` will try to set step option so there is a data point every 10 pixels. | | _Metric lookup_ | Search for metric names in this input field. | | _Format as_ | Switch between Table, Time series or Heatmap. Table format will only work in the Table panel. Heatmap format is suitable for displaying metrics having histogram type on Heatmap panel. Under the hood, it converts cumulative histogram to regular and sorts series by the bucket bound. | +| _Instant_ | Perform an "instant" query, to return only the latest value that Prometheus has scraped for the requested time series. Instant queries return results much faster than normal range queries. Use them to look up label sets. | > NOTE: Grafana slightly modifies the request dates for queries to align them with the dynamically calculated step. > This ensures consistent display of metrics data but can result in a small gap of data at the right edge of a graph. +### Instant queries + +The Prometheus datasource allows you to run "instant" queries, which queries only the latest value. +You can visualize the results in a table panel to see all available labels of a timeseries. + +Instant query results are made up only of one datapoint per series but can be shown in the graph panel with the help of [series overrides](/features/panels/graph/#series-overrides). +To show them in the graph as a latest value point, add a series override and select `Points > true`. +To show a horizontal line across the whole graph, add a series override and select `Transform > constant`. + +> Support for constant series overrides is available from Grafana v6.4 + ## Templating Instead of hard-coding things like server, application and sensor name in your metric queries, you can use variables in their place. diff --git a/packages/grafana-ui/src/utils/flotPairs.test.ts b/packages/grafana-ui/src/utils/flotPairs.test.ts index 292c4abfb9d9..d94518b630e6 100644 --- a/packages/grafana-ui/src/utils/flotPairs.test.ts +++ b/packages/grafana-ui/src/utils/flotPairs.test.ts @@ -1,5 +1,5 @@ -import { getFlotPairs } from './flotPairs'; -import { MutableDataFrame } from '@grafana/data'; +import { getFlotPairs, getFlotPairsConstant } from './flotPairs'; +import { MutableDataFrame, TimeRange, dateTime } from '@grafana/data'; describe('getFlotPairs', () => { const series = new MutableDataFrame({ @@ -33,3 +33,28 @@ describe('getFlotPairs', () => { expect(pairs[0][1]).toEqual('a'); }); }); + +describe('getFlotPairsConstant', () => { + const makeRange = (from: number, to: number): TimeRange => ({ + from: dateTime(from), + to: dateTime(to), + raw: { from: `${from}`, to: `${to}` }, + }); + + it('should return an empty series on empty data', () => { + const range: TimeRange = makeRange(0, 1); + const pairs = getFlotPairsConstant([], range); + expect(pairs).toMatchObject([]); + }); + + it('should return an empty series on missing range', () => { + const pairs = getFlotPairsConstant([], {} as TimeRange); + expect(pairs).toMatchObject([]); + }); + + it('should return an constant series for range', () => { + const range: TimeRange = makeRange(0, 1); + const pairs = getFlotPairsConstant([[2, 123], [4, 456]], range); + expect(pairs).toMatchObject([[0, 123], [1, 123]]); + }); +}); diff --git a/packages/grafana-ui/src/utils/flotPairs.ts b/packages/grafana-ui/src/utils/flotPairs.ts index 4470c8084432..81c7fcb59145 100644 --- a/packages/grafana-ui/src/utils/flotPairs.ts +++ b/packages/grafana-ui/src/utils/flotPairs.ts @@ -1,5 +1,5 @@ // Types -import { NullValueMode, GraphSeriesValue, Field } from '@grafana/data'; +import { NullValueMode, GraphSeriesValue, Field, TimeRange } from '@grafana/data'; export interface FlotPairsOptions { xField: Field; @@ -42,3 +42,19 @@ export function getFlotPairs({ xField, yField, nullValueMode }: FlotPairsOptions } return pairs; } + +/** + * Returns a constant series based on the first value from the provide series. + * @param seriesData Series + * @param range Start and end time for the constant series + */ +export function getFlotPairsConstant(seriesData: GraphSeriesValue[][], range: TimeRange): GraphSeriesValue[][] { + if (!range.from || !range.to || !seriesData || seriesData.length === 0) { + return []; + } + + const from = range.from.valueOf(); + const to = range.to.valueOf(); + const value = seriesData[0][1]; + return [[from, value], [to, value]]; +} diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 8f5457e4adf2..3684832000e6 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -4,7 +4,7 @@ export * from './namedColorsPalette'; export * from './displayProcessor'; export * from './fieldDisplay'; export * from './validate'; -export { getFlotPairs } from './flotPairs'; +export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; export * from './slate'; export * from './dataLinks'; export { default as ansicolor } from './ansicolor'; diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index c691bb8b5423..412346e2f853 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -24,7 +24,14 @@ import ReactDOM from 'react-dom'; import { GraphLegendProps, Legend } from './Legend/Legend'; import { GraphCtrl } from './module'; -import { getValueFormat, ContextMenuGroup, FieldDisplay, ContextMenuItem, getDisplayProcessor } from '@grafana/ui'; +import { + getValueFormat, + ContextMenuGroup, + FieldDisplay, + ContextMenuItem, + getDisplayProcessor, + getFlotPairsConstant, +} from '@grafana/ui'; import { provideTheme, getCurrentTheme } from 'app/core/utils/ConfigProvider'; import { toUtc, LinkModelSupplier, DataFrameView } from '@grafana/data'; import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; @@ -395,6 +402,10 @@ class GraphElement { const series = data[i]; series.data = series.getFlotPairs(series.nullPointMode || this.panel.nullPointMode); + if (series.transform === 'constant') { + series.data = getFlotPairsConstant(series.data, this.ctrl.range); + } + // if hidden remove points and disable stack if (this.ctrl.hiddenSeries[series.alias]) { series.data = []; diff --git a/public/app/plugins/panel/graph/series_overrides_ctrl.ts b/public/app/plugins/panel/graph/series_overrides_ctrl.ts index ecc504133c44..eabd448e073a 100644 --- a/public/app/plugins/panel/graph/series_overrides_ctrl.ts +++ b/public/app/plugins/panel/graph/series_overrides_ctrl.ts @@ -152,7 +152,7 @@ export function SeriesOverridesCtrl($scope: any, $element: JQuery, popoverSrv: a $scope.addOverrideOption('Color', 'color', ['change']); $scope.addOverrideOption('Y-axis', 'yaxis', [1, 2]); $scope.addOverrideOption('Z-index', 'zindex', [-3, -2, -1, 0, 1, 2, 3]); - $scope.addOverrideOption('Transform', 'transform', ['negative-Y']); + $scope.addOverrideOption('Transform', 'transform', ['constant', 'negative-Y']); $scope.addOverrideOption('Legend', 'legend', [true, false]); $scope.addOverrideOption('Hide in tooltip', 'hideTooltip', [true, false]); $scope.updateCurrentOverrides(); From 97ff75732d228d7701101514b8c4e6b3efc31100 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 16 Sep 2019 14:06:50 +0200 Subject: [PATCH 57/87] Routing: Update routing to require sign in on every route (#19118) * Update routing to require sign in on every route * Review update --- pkg/api/api.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 6a590e28cfb0..f84299cbe624 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -24,7 +24,6 @@ func (hs *HTTPServer) registerRoutes() { r := hs.RouteRegister // not logged in views - r.Get("/", reqSignedIn, hs.Index) r.Get("/logout", hs.Logout) r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost)) r.Get("/login/:name", quota("session"), hs.OAuthLogin) @@ -422,4 +421,6 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/api/snapshots/:key", GetDashboardSnapshot) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, Wrap(DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) + + r.Get("/*", reqSignedIn, hs.Index) } From e9f1e86c8e19c27eed3243be93252e5e8d14905d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 16 Sep 2019 14:17:33 +0200 Subject: [PATCH 58/87] Fix: Fixes crash using back button with zoomed graph (#19122) Fixes: #19114 --- .../features/explore/state/reducers.test.ts | 28 ++++++++++++++++++- public/app/features/explore/state/reducers.ts | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index bf09628ef708..79bd276e9acb 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -20,6 +20,7 @@ import { scanStopAction, toggleGraphAction, toggleTableAction, + changeRangeAction, } from './actionTypes'; import { Reducer } from 'redux'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; @@ -27,7 +28,7 @@ import { updateLocation } from 'app/core/actions/location'; import { serializeStateToUrlParam } from 'app/core/utils/explore'; import TableModel from 'app/core/table_model'; import { DataSourceApi, DataQuery } from '@grafana/ui'; -import { LogsModel, LogsDedupStrategy } from '@grafana/data'; +import { LogsModel, LogsDedupStrategy, dateTime } from '@grafana/data'; describe('Explore item reducer', () => { describe('scanning', () => { @@ -196,6 +197,31 @@ describe('Explore item reducer', () => { }); }); }); + + describe('changing range', () => { + describe('when changeRangeAction is dispatched', () => { + it('then it should set correct state', () => { + reducerTester() + .givenReducer(itemReducer, { + update: { ...makeInitialUpdateState(), range: true }, + range: null, + absoluteRange: null, + }) + .whenActionIsDispatched( + changeRangeAction({ + exploreId: ExploreId.left, + absoluteRange: { from: 1546297200000, to: 1546383600000 }, + range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } }, + }) + ) + .thenStateShouldEqual({ + update: { ...makeInitialUpdateState(), range: false }, + absoluteRange: { from: 1546297200000, to: 1546383600000 }, + range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } }, + }); + }); + }); + }); }); export const setup = (urlStateOverrides?: any) => { diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 27bdd4b6b58f..899c60eec26b 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -540,6 +540,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta ...state, range, absoluteRange, + update: makeInitialUpdateState(), }; }, }) From 99c799e9b7fe4551cca25f5d59b494b009cb9f4a Mon Sep 17 00:00:00 2001 From: gotjosh Date: Mon, 16 Sep 2019 14:13:35 +0100 Subject: [PATCH 59/87] Close the connection only if we establish it. (#18897) --- pkg/services/ldap/ldap.go | 14 +++++++++++ pkg/services/ldap/ldap_test.go | 31 ++++++++++++++++++++++-- pkg/services/ldap/testing.go | 6 ++++- pkg/services/multildap/multildap.go | 3 +-- pkg/services/multildap/multildap_test.go | 6 +++-- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index b400fafa815d..d079d632f25d 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -46,6 +46,9 @@ type Server struct { // Bind authenticates the connection with the LDAP server // - with the username and password setup in the config // - or, anonymously +// +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. func (server *Server) Bind() error { if server.shouldAdminBind() { if err := server.AdminBind(); err != nil { @@ -139,6 +142,8 @@ func (server *Server) Dial() error { } // Close closes the LDAP connection +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. func (server *Server) Close() { server.Connection.Close() } @@ -158,6 +163,9 @@ func (server *Server) Close() { // user without login/password binding with LDAP server, in such case // we will perform "unauthenticated bind", then search for the // targeted user and then perform the bind with passed login/password. +// +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. func (server *Server) Login(query *models.LoginUserQuery) ( *models.ExternalUserInfo, error, ) { @@ -231,6 +239,8 @@ func (server *Server) shouldSingleBind() bool { } // Users gets LDAP users by logins +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. func (server *Server) Users(logins []string) ( []*models.ExternalUserInfo, error, @@ -414,6 +424,8 @@ func (server *Server) buildGrafanaUser(user *ldap.Entry) (*models.ExternalUserIn } // UserBind binds the user with the LDAP server +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. func (server *Server) UserBind(username, password string) error { err := server.userBind(username, password) if err != nil { @@ -429,6 +441,8 @@ func (server *Server) UserBind(username, password string) error { } // AdminBind binds "admin" user with LDAP +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. func (server *Server) AdminBind() error { err := server.userBind(server.Config.BindDN, server.Config.BindPassword) if err != nil { diff --git a/pkg/services/ldap/ldap_test.go b/pkg/services/ldap/ldap_test.go index 9f89bf438330..ea1fd049bf37 100644 --- a/pkg/services/ldap/ldap_test.go +++ b/pkg/services/ldap/ldap_test.go @@ -4,10 +4,9 @@ import ( "errors" "testing" + "github.com/grafana/grafana/pkg/infra/log" . "github.com/smartystreets/goconvey/convey" "gopkg.in/ldap.v3" - - "github.com/grafana/grafana/pkg/infra/log" ) func TestPublicAPI(t *testing.T) { @@ -22,6 +21,34 @@ func TestPublicAPI(t *testing.T) { }) }) + Convey("Close()", t, func() { + Convey("Should close the connection", func() { + connection := &MockConnection{} + + server := &Server{ + Config: &ServerConfig{ + Attr: AttributeMap{}, + SearchBaseDNs: []string{"BaseDNHere"}, + }, + Connection: connection, + } + + So(server.Close, ShouldNotPanic) + So(connection.CloseCalled, ShouldBeTrue) + }) + + Convey("Should panic if no connection is established", func() { + server := &Server{ + Config: &ServerConfig{ + Attr: AttributeMap{}, + SearchBaseDNs: []string{"BaseDNHere"}, + }, + Connection: nil, + } + + So(server.Close, ShouldPanic) + }) + }) Convey("Users()", t, func() { Convey("Finds one user", func() { MockConnection := &MockConnection{} diff --git a/pkg/services/ldap/testing.go b/pkg/services/ldap/testing.go index 7e0b33fcbd50..8bad83a2d926 100644 --- a/pkg/services/ldap/testing.go +++ b/pkg/services/ldap/testing.go @@ -19,6 +19,8 @@ type MockConnection struct { DelParams *ldap.DelRequest DelCalled bool + CloseCalled bool + UnauthenticatedBindCalled bool BindCalled bool @@ -49,7 +51,9 @@ func (c *MockConnection) UnauthenticatedBind(username string) error { } // Close mocks Close connection function -func (c *MockConnection) Close() {} +func (c *MockConnection) Close() { + c.CloseCalled = true +} func (c *MockConnection) setSearchResult(result *ldap.SearchResult) { c.SearchResult = result diff --git a/pkg/services/multildap/multildap.go b/pkg/services/multildap/multildap.go index 953654ff4835..b79a21e0417f 100644 --- a/pkg/services/multildap/multildap.go +++ b/pkg/services/multildap/multildap.go @@ -85,13 +85,12 @@ func (multiples *MultiLDAP) Ping() ([]*ServerStatus, error) { if err == nil { status.Available = true serverStatuses = append(serverStatuses, status) + server.Close() } else { status.Available = false status.Error = err serverStatuses = append(serverStatuses, status) } - - defer server.Close() } return serverStatuses, nil diff --git a/pkg/services/multildap/multildap_test.go b/pkg/services/multildap/multildap_test.go index 89877320847b..6554bdbaabf5 100644 --- a/pkg/services/multildap/multildap_test.go +++ b/pkg/services/multildap/multildap_test.go @@ -40,11 +40,12 @@ func TestMultiLDAP(t *testing.T) { So(statuses[0].Port, ShouldEqual, 361) So(statuses[0].Available, ShouldBeFalse) So(statuses[0].Error, ShouldEqual, expectedErr) + So(mock.closeCalledTimes, ShouldEqual, 0) teardown() }) - Convey("Shoudl get the LDAP server statuses", func() { - setup() + Convey("Should get the LDAP server statuses", func() { + mock := setup() multi := New([]*ldap.ServerConfig{ {Host: "10.0.0.1", Port: 361}, @@ -57,6 +58,7 @@ func TestMultiLDAP(t *testing.T) { So(statuses[0].Port, ShouldEqual, 361) So(statuses[0].Available, ShouldBeTrue) So(statuses[0].Error, ShouldBeNil) + So(mock.closeCalledTimes, ShouldEqual, 1) teardown() }) From 1a715014405043b0cc182804f02087719ca25084 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 16 Sep 2019 15:50:15 +0200 Subject: [PATCH 60/87] Chore: cross-package security bumps (#19131) * Bump lodash to 4.17.15 * Bump pretty-format to 24.9.0 --- package.json | 2 +- packages/grafana-data/package.json | 4 ++-- packages/grafana-runtime/package.json | 10 ++++----- packages/grafana-toolkit/package.json | 2 +- packages/grafana-ui/package.json | 4 ++-- yarn.lock | 31 ++++++++++----------------- 6 files changed, 22 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index a6ad0364d5c1..2e6792aaee97 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "immutable": "3.8.2", "is-hotkey": "0.1.4", "jquery": "3.4.1", - "lodash": "4.17.14", + "lodash": "4.17.15", "marked": "0.6.2", "memoize-one": "5.1.1", "moment": "2.24.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 634c74f6d957..170215e468ef 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -28,8 +28,8 @@ "@types/pretty-format": "20.0.1", "@types/react": "16.8.16", "@types/sinon": "^7.0.11", - "lodash": "^4.17.10", - "pretty-format": "^24.5.0", + "lodash": "4.17.15", + "pretty-format": "24.9.0", "rollup": "1.6.0", "rollup-plugin-commonjs": "9.2.1", "rollup-plugin-node-resolve": "4.0.1", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index b40a8ff9c9cf..3614065777f8 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -21,15 +21,15 @@ "build": "grafana-toolkit package:build --scope=runtime" }, "dependencies": { - "systemjs": "0.20.19", - "systemjs-plugin-css": "0.1.37", "@grafana/data": "^6.4.0-alpha", - "@grafana/ui": "^6.4.0-alpha" + "@grafana/ui": "^6.4.0-alpha", + "systemjs": "0.20.19", + "systemjs-plugin-css": "0.1.37" }, "devDependencies": { "@types/systemjs": "^0.20.6", - "lodash": "^4.17.10", - "pretty-format": "^24.5.0", + "lodash": "4.17.15", + "pretty-format": "24.9.0", "rollup": "1.6.0", "rollup-plugin-commonjs": "9.2.1", "rollup-plugin-node-resolve": "4.0.1", diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index eab6081f3982..3d1357d8c0cf 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -61,7 +61,7 @@ "jest-cli": "^24.8.0", "jest-coverage-badges": "^1.1.2", "jest-junit": "^6.4.0", - "lodash": "4.17.14", + "lodash": "4.17.15", "md5-file": "^4.0.0", "mini-css-extract-plugin": "^0.7.0", "node-sass": "^4.12.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 4d0ea1bbf329..6fdc543fdb48 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -31,7 +31,7 @@ "classnames": "2.2.6", "d3": "5.9.1", "jquery": "3.4.1", - "lodash": "4.17.14", + "lodash": "4.17.15", "moment": "2.24.0", "papaparse": "4.6.3", "rc-time-picker": "^3.7.2", @@ -70,7 +70,7 @@ "@types/storybook__addon-knobs": "4.0.4", "@types/storybook__react": "4.0.1", "@types/tinycolor2": "1.4.1", - "pretty-format": "24.5.0", + "pretty-format": "24.9.0", "react-docgen-typescript-loader": "3.0.1", "react-docgen-typescript-webpack-plugin": "1.1.0", "react-test-renderer": "16.8.4", diff --git a/yarn.lock b/yarn.lock index 435e9de871f3..4f0afd752ad9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1406,7 +1406,7 @@ source-map "^0.6.1" write-file-atomic "2.4.1" -"@jest/types@^24.5.0", "@jest/types@^24.8.0": +"@jest/types@^24.8.0": version "24.8.0" resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.8.0.tgz#f31e25948c58f0abd8c845ae26fcea1491dea7ad" dependencies: @@ -11476,10 +11476,10 @@ lodash.without@~4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" -lodash@4.17.14: - version "4.17.14" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" - integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== +lodash@4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== lodash@>4.17.4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.1.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.7.0, lodash@^4.8.0, lodash@~4.17.10, lodash@~4.17.5: version "4.17.11" @@ -14310,11 +14310,12 @@ pretty-error@^2.0.2, pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@24.5.0: - version "24.5.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.5.0.tgz#cc69a0281a62cd7242633fc135d6930cd889822d" +pretty-format@24.9.0, pretty-format@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" + integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== dependencies: - "@jest/types" "^24.5.0" + "@jest/types" "^24.9.0" ansi-regex "^4.0.0" ansi-styles "^3.2.0" react-is "^16.8.4" @@ -14326,7 +14327,7 @@ pretty-format@^21.2.1: ansi-regex "^3.0.0" ansi-styles "^3.2.0" -pretty-format@^24.5.0, pretty-format@^24.7.0, pretty-format@^24.8.0: +pretty-format@^24.7.0, pretty-format@^24.8.0: version "24.8.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.8.0.tgz#8dae7044f58db7cb8be245383b565a963e3c27f2" dependencies: @@ -14335,16 +14336,6 @@ pretty-format@^24.5.0, pretty-format@^24.7.0, pretty-format@^24.8.0: ansi-styles "^3.2.0" react-is "^16.8.4" -pretty-format@^24.9.0: - version "24.9.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" - integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== - dependencies: - "@jest/types" "^24.9.0" - ansi-regex "^4.0.0" - ansi-styles "^3.2.0" - react-is "^16.8.4" - pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" From ba11958a527ffb899d4ea96fd3ad53f6241a5809 Mon Sep 17 00:00:00 2001 From: James Beckett <308470+hackery@users.noreply.github.com> Date: Mon, 16 Sep 2019 16:41:53 +0100 Subject: [PATCH 61/87] Elasticsearch: allow templating queries to order by doc_count (#18870) * Elasticsearch Datasource: allow templating queries to order by doc_count * Elasticsearch Datasource: add tests for doc_count templating queries --- .../features/datasources/elasticsearch.md | 9 +++ .../datasource/elasticsearch/query_builder.ts | 29 ++++++-- .../elasticsearch/specs/query_builder.test.ts | 68 ++++++++++++++++++- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 8c07a187a5e8..087e4e5b9c5b 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -143,6 +143,15 @@ You can use other variables inside the query. Example query definition for a var In the above example, we use another variable named `$source` inside the query definition. Whenever you change, via the dropdown, the current value of the ` $source` variable, it will trigger an update of the `$host` variable so it now only contains hostnames filtered by in this case the `@source` document property. +These queries by default return results in term order (which can then be sorted alphabetically or numerically as for any variable). +To produce a list of terms sorted by doc count (a top-N values list), add an `orderBy` property of "doc_count". +This automatically selects a descending sort; using "asc" with doc_count (a bottom-N list) can be done by setting `order: "asc"` but [is discouraged](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order) as it "increases the error on document counts". +To keep terms in the doc count order, set the variable's Sort dropdown to **Disabled**; you might alternatively still want to use e.g. **Alphabetical** to re-sort them. + +``` +{"find": "terms", "field": "@hostname", "orderBy": "doc_count"} +``` + ### Using variables in queries There are two syntaxes: diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.ts b/public/app/plugins/datasource/elasticsearch/query_builder.ts index 5dbfbb12f2d7..beeecad271f0 100644 --- a/public/app/plugins/datasource/elasticsearch/query_builder.ts +++ b/public/app/plugins/datasource/elasticsearch/query_builder.ts @@ -353,17 +353,32 @@ export class ElasticQueryBuilder { terms: { field: queryDef.field, size: size, - order: { - _term: 'asc', - }, + order: {}, }, }, }; - if (this.esVersion >= 60) { - query.aggs['1'].terms.order = { - _key: 'asc', - }; + // Default behaviour is to order results by { _key: asc } + // queryDef.order allows selection of asc/desc + // queryDef.orderBy allows selection of doc_count ordering (defaults desc) + + const { orderBy = 'key', order = orderBy === 'doc_count' ? 'desc' : 'asc' } = queryDef; + + if (['asc', 'desc'].indexOf(order) < 0) { + throw { message: `Invalid query sort order ${order}` }; + } + + switch (orderBy) { + case 'key': + case 'term': + const keyname = this.esVersion >= 60 ? '_key' : '_term'; + query.aggs['1'].terms.order[keyname] = order; + break; + case 'doc_count': + query.aggs['1'].terms.order['_count'] = order; + break; + default: + throw { message: `Invalid query sort type ${orderBy}` }; } return query; diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts b/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts index bf7e54586c63..ed5ae0736c8d 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts @@ -478,20 +478,84 @@ describe('ElasticQueryBuilder', () => { expect(query.query.bool.filter[5].bool.must_not.regexp['key6']).toBe('value6'); }); - it('getTermsQuery should set correct sorting', () => { + // terms query ES<6.0 - check ordering for _term and doc_type + + it('getTermsQuery(default case) es<6.0 should set asc sorting on _term', () => { const query = builder.getTermsQuery({}); expect(query.aggs['1'].terms.order._term).toBe('asc'); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBeUndefined(); + }); + + it('getTermsQuery(order:desc) es<6.0 should set desc sorting on _term', () => { + const query = builder.getTermsQuery({ order: 'desc' }); + expect(query.aggs['1'].terms.order._term).toBe('desc'); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBeUndefined(); + }); + + it('getTermsQuery(orderBy:doc_count) es<6.0 should set desc sorting on _count', () => { + const query = builder.getTermsQuery({ orderBy: 'doc_count' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBe('desc'); + }); + + it('getTermsQuery(orderBy:doc_count, order:asc) es<6.0 should set asc sorting on _count', () => { + const query = builder.getTermsQuery({ orderBy: 'doc_count', order: 'asc' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBe('asc'); }); - it('getTermsQuery es6.x should set correct sorting', () => { + // terms query ES>=6.0 - check ordering for _key and doc_type + + it('getTermsQuery(default case) es6.x should set asc sorting on _key', () => { const builder6x = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 60, }); const query = builder6x.getTermsQuery({}); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); expect(query.aggs['1'].terms.order._key).toBe('asc'); + expect(query.aggs['1'].terms.order._count).toBeUndefined(); + }); + + it('getTermsQuery(order:desc) es6.x should set desc sorting on _key', () => { + const builder6x = new ElasticQueryBuilder({ + timeField: '@timestamp', + esVersion: 60, + }); + const query = builder6x.getTermsQuery({ order: 'desc' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBe('desc'); + expect(query.aggs['1'].terms.order._count).toBeUndefined(); }); + it('getTermsQuery(orderBy:doc_count) es6.x should set desc sorting on _count', () => { + const builder6x = new ElasticQueryBuilder({ + timeField: '@timestamp', + esVersion: 60, + }); + const query = builder6x.getTermsQuery({ orderBy: 'doc_count' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBe('desc'); + }); + + it('getTermsQuery(orderBy:doc_count, order:asc) es6.x should set asc sorting on _count', () => { + const builder6x = new ElasticQueryBuilder({ + timeField: '@timestamp', + esVersion: 60, + }); + const query = builder6x.getTermsQuery({ orderBy: 'doc_count', order: 'asc' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBe('asc'); + }); + + // Logs query + it('getTermsQuery should request documents and date histogram', () => { const query = builder.getLogsQuery({}); expect(query).toHaveProperty('query.bool.filter'); From 3c61b563c3e51caf0d0b6334bf5ab06d746dfe1a Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 16 Sep 2019 17:56:01 +0200 Subject: [PATCH 62/87] Ldap: Add LDAP debug page (#18759) * Add items for navmodel and basic page * add reducer and actions * adding user mapping table component * adding components for ldap tables * add alert box on error * close error alert box * LDAP status page: connect APIs WIP * LDAP debug: fetch connection status from API * LDAP debug: fetch user info from API * LDAP debug: improve connection error view * LDAP debug: connection error tweaks * LDAP debug: fix role mapping view * LDAP debug: role mapping view tweaks * LDAP debug: add bulk-sync button stub * LDAP debug: minor refactor * LDAP debug: show user teams * LDAP debug: user info refactor * LDAP debug: initial user page * LDAP debug: minor refactor, remove unused angular wrapper * LDAP debug: add sessions to user page * LDAP debug: tweak user page * LDAP debug: tweak view for disabled user * LDAP debug: get sync info from API * LDAP debug: user sync info * LDAP debug: sync user button * LDAP debug: clear error on page load * LDAP debug: add user last sync info * LDAP debug: actions refactor * LDAP debug: roles and teams style tweaks * Pass showAttributeMapping to LdapUserTeams * LDAP debug: hide bulk sync button * LDAP debug: refactor sessions component * LDAP debug: fix loading user sessions * LDAP debug: hide sync user button * LDAP debug: fix fetching unavailable /ldap-sync-status endpoint * LDAP debug: revert accidentally added fix * LDAP debug: show error when LDAP is not enabled * LDAP debug: refactor, move ldap components into ldap/ folder * LDAP debug: styles refactoring * LDAP debug: ldap reducer tests * LDAP debug: ldap user reducer tests * LDAP debug: fix connection error placement * Text update * LdapUser: Minor UI changes moving things around * AlertBox: Removed icon-on-top as everywhere else it is centered, want to have it be consistent --- .../src/services/backendSrv.ts | 6 +- pkg/api/index.go | 1 + pkg/api/ldap_debug.go | 4 + .../app/core/components/AlertBox/AlertBox.tsx | 4 +- .../app/features/admin/DisabledUserInfo.tsx | 22 ++ public/app/features/admin/UserInfo.tsx | 36 +++ public/app/features/admin/UserSessions.tsx | 68 ++++++ public/app/features/admin/UserSyncInfo.tsx | 69 ++++++ .../admin/ldap/LdapConnectionStatus.tsx | 82 +++++++ public/app/features/admin/ldap/LdapPage.tsx | 141 ++++++++++++ .../app/features/admin/ldap/LdapSyncInfo.tsx | 73 ++++++ .../features/admin/ldap/LdapUserGroups.tsx | 56 +++++ .../app/features/admin/ldap/LdapUserInfo.tsx | 26 +++ .../admin/ldap/LdapUserMappingInfo.tsx | 46 ++++ .../app/features/admin/ldap/LdapUserPage.tsx | 159 +++++++++++++ .../admin/ldap/LdapUserPermissions.tsx | 50 +++++ .../app/features/admin/ldap/LdapUserTeams.tsx | 55 +++++ public/app/features/admin/partials/users.html | 10 +- public/app/features/admin/state/actions.ts | 137 ++++++++++++ public/app/features/admin/state/apis.ts | 63 ++++++ .../app/features/admin/state/reducers.test.ts | 210 ++++++++++++++++++ public/app/features/admin/state/reducers.ts | 135 +++++++++++ public/app/routes/routes.ts | 14 ++ public/app/store/configureStore.ts | 2 + public/app/types/index.ts | 1 + public/app/types/ldap.ts | 95 ++++++++ public/app/types/store.ts | 3 + public/sass/components/_alerts.scss | 4 + 28 files changed, 1563 insertions(+), 9 deletions(-) create mode 100644 public/app/features/admin/DisabledUserInfo.tsx create mode 100644 public/app/features/admin/UserInfo.tsx create mode 100644 public/app/features/admin/UserSessions.tsx create mode 100644 public/app/features/admin/UserSyncInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapConnectionStatus.tsx create mode 100644 public/app/features/admin/ldap/LdapPage.tsx create mode 100644 public/app/features/admin/ldap/LdapSyncInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserGroups.tsx create mode 100644 public/app/features/admin/ldap/LdapUserInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserMappingInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserPage.tsx create mode 100644 public/app/features/admin/ldap/LdapUserPermissions.tsx create mode 100644 public/app/features/admin/ldap/LdapUserTeams.tsx create mode 100644 public/app/features/admin/state/actions.ts create mode 100644 public/app/features/admin/state/reducers.test.ts create mode 100644 public/app/features/admin/state/reducers.ts create mode 100644 public/app/types/ldap.ts diff --git a/packages/grafana-runtime/src/services/backendSrv.ts b/packages/grafana-runtime/src/services/backendSrv.ts index a30296eca8cc..68e07c18df7f 100644 --- a/packages/grafana-runtime/src/services/backendSrv.ts +++ b/packages/grafana-runtime/src/services/backendSrv.ts @@ -20,11 +20,11 @@ export interface BackendSrv { delete(url: string): Promise; - post(url: string, data: any): Promise; + post(url: string, data?: any): Promise; - patch(url: string, data: any): Promise; + patch(url: string, data?: any): Promise; - put(url: string, data: any): Promise; + put(url: string, data?: any): Promise; // If there is an error, set: err.isHandled = true // otherwise the backend will show a message for you diff --git a/pkg/api/index.go b/pkg/api/index.go index 3f5018f14e78..c22f3498a6a0 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -318,6 +318,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er {Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"}, {Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"}, {Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"}, + {Text: "LDAP", Id: "ldap", Url: setting.AppSubUrl + "/admin/ldap", Icon: "fa fa-fw fa-address-book-o"}, }, }) } diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index 7ccbb8fbecac..b5f9d91bf4af 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -129,6 +129,10 @@ func (server *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response { ldap := newLDAP(ldapConfig.Servers) + if ldap == nil { + return Error(http.StatusInternalServerError, "Failed to find the LDAP server", nil) + } + statuses, err := ldap.Ping() if err != nil { diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx index 3bedf71dd5be..c9e19920fbbe 100644 --- a/public/app/core/components/AlertBox/AlertBox.tsx +++ b/public/app/core/components/AlertBox/AlertBox.tsx @@ -1,4 +1,5 @@ import React, { FunctionComponent, ReactNode } from 'react'; +import classNames from 'classnames'; import { AppNotificationSeverity } from 'app/types'; interface Props { @@ -23,8 +24,9 @@ function getIconFromSeverity(severity: AppNotificationSeverity): string { } export const AlertBox: FunctionComponent = ({ title, icon, body, severity, onClose }) => { + const alertClass = classNames('alert', `alert-${severity}`); return ( -
+
diff --git a/public/app/features/admin/DisabledUserInfo.tsx b/public/app/features/admin/DisabledUserInfo.tsx new file mode 100644 index 000000000000..4b7000b79801 --- /dev/null +++ b/public/app/features/admin/DisabledUserInfo.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import { UserInfo } from './UserInfo'; +import { LdapUserPermissions } from './ldap/LdapUserPermissions'; +import { User } from 'app/types'; + +interface Props { + user: User; +} + +export const DisabledUserInfo: FC = ({ user }) => { + return ( + <> + + + + ); +}; diff --git a/public/app/features/admin/UserInfo.tsx b/public/app/features/admin/UserInfo.tsx new file mode 100644 index 000000000000..a71a75279782 --- /dev/null +++ b/public/app/features/admin/UserInfo.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { User } from 'app/types'; + +interface Props { + user: User; +} + +export const UserInfo: FC = ({ user }) => { + return ( +
+
+ + + + + + + + + + + + + + + + + + + + +
User information
Name{user.name}
Username{user.login}
Email{user.email}
+
+
+ ); +}; diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx new file mode 100644 index 000000000000..c07c90227caf --- /dev/null +++ b/public/app/features/admin/UserSessions.tsx @@ -0,0 +1,68 @@ +import React, { PureComponent } from 'react'; +import { UserSession } from 'app/types'; + +interface Props { + sessions: UserSession[]; + + onSessionRevoke: (id: number) => void; + onAllSessionsRevoke: () => void; +} + +export class UserSessions extends PureComponent { + handleSessionRevoke = (id: number) => { + return () => { + this.props.onSessionRevoke(id); + }; + }; + + handleAllSessionsRevoke = () => { + this.props.onAllSessionsRevoke(); + }; + + render() { + const { sessions } = this.props; + + return ( + <> +

Sessions

+
+
+ + + + + + + + + + + {sessions && + sessions.map((session, index) => ( + + + + + + + + ))} + +
Last seenLogged onIP addressBrowser & OS
{session.isActive ? 'Now' : session.seenAt}{session.createdAt}{session.clientIp}{`${session.browser} on ${session.os} ${session.osVersion}`} + +
+
+
+ {sessions.length > 0 && ( + + )} +
+
+ + ); + } +} diff --git a/public/app/features/admin/UserSyncInfo.tsx b/public/app/features/admin/UserSyncInfo.tsx new file mode 100644 index 000000000000..577014d03922 --- /dev/null +++ b/public/app/features/admin/UserSyncInfo.tsx @@ -0,0 +1,69 @@ +import React, { PureComponent } from 'react'; +import { dateTime } from '@grafana/data'; +import { LdapUserSyncInfo } from 'app/types'; + +interface Props { + syncInfo: LdapUserSyncInfo; + onSync?: () => void; +} + +interface State { + isSyncing: boolean; +} + +const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz'; + +export class UserSyncInfo extends PureComponent { + state = { + isSyncing: false, + }; + + handleSyncClick = async () => { + const { onSync } = this.props; + this.setState({ isSyncing: true }); + try { + if (onSync) { + await onSync(); + } + } finally { + this.setState({ isSyncing: false }); + } + }; + + render() { + const { syncInfo } = this.props; + const { isSyncing } = this.state; + const nextSyncTime = syncInfo.nextSync ? dateTime(syncInfo.nextSync).format(syncTimeFormat) : ''; + const prevSyncSuccessful = syncInfo && syncInfo.prevSync; + const prevSyncTime = prevSyncSuccessful ? dateTime(syncInfo.prevSync).format(syncTimeFormat) : ''; + + return ( + <> +

+ LDAP + +

+
+
+ + + + + + {prevSyncSuccessful && } + + + + + + +
Last synchronisation{prevSyncTime}Successful
Next scheduled synchronisation{nextSyncTime}
+
+
+ + ); + } +} diff --git a/public/app/features/admin/ldap/LdapConnectionStatus.tsx b/public/app/features/admin/ldap/LdapConnectionStatus.tsx new file mode 100644 index 000000000000..f289bb74407b --- /dev/null +++ b/public/app/features/admin/ldap/LdapConnectionStatus.tsx @@ -0,0 +1,82 @@ +import React, { FC } from 'react'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; +import { AppNotificationSeverity, LdapConnectionInfo, LdapServerInfo } from 'app/types'; + +interface Props { + ldapConnectionInfo: LdapConnectionInfo; +} + +export const LdapConnectionStatus: FC = ({ ldapConnectionInfo }) => { + return ( + <> +

LDAP Connection

+
+
+ + + + + + + + + {ldapConnectionInfo && + ldapConnectionInfo.map((serverInfo, index) => ( + + + + + + ))} + +
HostPort
{serverInfo.host}{serverInfo.port} + {serverInfo.available ? ( + + ) : ( + + )} +
+
+
+ +
+
+ + ); +}; + +interface LdapConnectionErrorProps { + ldapConnectionInfo: LdapConnectionInfo; +} + +export const LdapErrorBox: FC = ({ ldapConnectionInfo }) => { + const hasError = ldapConnectionInfo.some(info => info.error); + if (!hasError) { + return null; + } + + const connectionErrors: LdapServerInfo[] = []; + ldapConnectionInfo.forEach(info => { + if (info.error) { + connectionErrors.push(info); + } + }); + + const errorElements = connectionErrors.map((info, index) => ( +
+ + {info.host}:{info.port} +
+
+ {info.error} + {index !== connectionErrors.length - 1 && ( + <> +
+
+ + )} +
+ )); + + return ; +}; diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx new file mode 100644 index 000000000000..57b865badc30 --- /dev/null +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -0,0 +1,141 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { NavModel } from '@grafana/data'; +import { FormField } from '@grafana/ui'; +import { getNavModel } from 'app/core/selectors/navModel'; +import config from 'app/core/config'; +import Page from 'app/core/components/Page/Page'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; +import { LdapConnectionStatus } from './LdapConnectionStatus'; +import { LdapSyncInfo } from './LdapSyncInfo'; +import { LdapUserInfo } from './LdapUserInfo'; +import { AppNotificationSeverity, LdapError, LdapUser, StoreState, SyncInfo, LdapConnectionInfo } from 'app/types'; +import { + loadLdapState, + loadLdapSyncStatus, + loadUserMapping, + clearUserError, + clearUserMappingInfo, +} from '../state/actions'; + +interface Props { + navModel: NavModel; + ldapConnectionInfo: LdapConnectionInfo; + ldapUser: LdapUser; + ldapSyncInfo: SyncInfo; + ldapError: LdapError; + userError?: LdapError; + + loadLdapState: typeof loadLdapState; + loadLdapSyncStatus: typeof loadLdapSyncStatus; + loadUserMapping: typeof loadUserMapping; + clearUserError: typeof clearUserError; + clearUserMappingInfo: typeof clearUserMappingInfo; +} + +interface State { + isLoading: boolean; +} + +export class LdapPage extends PureComponent { + state = { + isLoading: true, + }; + + async componentDidMount() { + await this.props.clearUserMappingInfo(); + await this.fetchLDAPStatus(); + this.setState({ isLoading: false }); + } + + async fetchLDAPStatus() { + const { loadLdapState, loadLdapSyncStatus } = this.props; + return Promise.all([loadLdapState(), loadLdapSyncStatus()]); + } + + async fetchUserMapping(username: string) { + const { loadUserMapping } = this.props; + return await loadUserMapping(username); + } + + search = (event: any) => { + event.preventDefault(); + const username = event.target.elements['username'].value; + if (username) { + this.fetchUserMapping(username); + } + }; + + onClearUserError = () => { + this.props.clearUserError(); + }; + + render() { + const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel } = this.props; + const { isLoading } = this.state; + + return ( + + + <> + {ldapError && ldapError.title && ( +
+ +
+ )} + + + + {config.buildInfo.isEnterprise && ldapSyncInfo && } + +

User mapping

+
+
+ + + +
+ {userError && userError.title && ( +
+ +
+ )} + {ldapUser && } + +
+
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'ldap'), + ldapConnectionInfo: state.ldap.connectionInfo, + ldapUser: state.ldap.user, + ldapSyncInfo: state.ldap.syncInfo, + userError: state.ldap.userError, + ldapError: state.ldap.ldapError, +}); + +const mapDispatchToProps = { + loadLdapState, + loadLdapSyncStatus, + loadUserMapping, + clearUserError, + clearUserMappingInfo, +}; + +export default hot(module)( + connect( + mapStateToProps, + mapDispatchToProps + )(LdapPage) +); diff --git a/public/app/features/admin/ldap/LdapSyncInfo.tsx b/public/app/features/admin/ldap/LdapSyncInfo.tsx new file mode 100644 index 000000000000..e03352f1cbca --- /dev/null +++ b/public/app/features/admin/ldap/LdapSyncInfo.tsx @@ -0,0 +1,73 @@ +import React, { PureComponent } from 'react'; +import { dateTime } from '@grafana/data'; +import { SyncInfo } from 'app/types'; + +interface Props { + ldapSyncInfo: SyncInfo; +} + +interface State { + isSyncing: boolean; +} + +const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz'; + +export class LdapSyncInfo extends PureComponent { + state = { + isSyncing: false, + }; + + handleSyncClick = () => { + console.log('Bulk-sync now'); + this.setState({ isSyncing: !this.state.isSyncing }); + }; + + render() { + const { ldapSyncInfo } = this.props; + const { isSyncing } = this.state; + const nextSyncTime = dateTime(ldapSyncInfo.nextSync).format(syncTimeFormat); + const prevSyncSuccessful = ldapSyncInfo && ldapSyncInfo.prevSync; + const prevSyncTime = prevSyncSuccessful ? dateTime(ldapSyncInfo.prevSync.started).format(syncTimeFormat) : ''; + + return ( + <> +

+ LDAP Synchronisation + +

+
+
+ + + + + + + + + + + + + + + + + {prevSyncSuccessful && ( + <> + + + + )} + + +
Active synchronisation{ldapSyncInfo.enabled ? 'Enabled' : 'Disabled'}
Scheduled{ldapSyncInfo.schedule}
Next scheduled synchronisation{nextSyncTime}
Last synchronisation{prevSyncTime}Successful
+
+
+ + ); + } +} diff --git a/public/app/features/admin/ldap/LdapUserGroups.tsx b/public/app/features/admin/ldap/LdapUserGroups.tsx new file mode 100644 index 000000000000..d38fbb956cce --- /dev/null +++ b/public/app/features/admin/ldap/LdapUserGroups.tsx @@ -0,0 +1,56 @@ +import React, { FC } from 'react'; +import { Tooltip } from '@grafana/ui'; +import { LdapRole } from 'app/types'; + +interface Props { + groups: LdapRole[]; + showAttributeMapping?: boolean; +} + +export const LdapUserGroups: FC = ({ groups, showAttributeMapping }) => { + const items = showAttributeMapping ? groups : groups.filter(item => item.orgRole); + const roleColumnClass = showAttributeMapping && 'width-14'; + + return ( +
+
+ + + + + + {showAttributeMapping && } + + + + {items.map((group, index) => { + return ( + + + + {showAttributeMapping && ( + <> + + + + )} + + ); + })} + +
OrganisationRoleLDAP Group
{group.orgName}{group.orgRole}{group.groupDN} + {!group.orgRole && ( + + No match + +
+ +
+
+
+ )} +
+
+
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapUserInfo.tsx b/public/app/features/admin/ldap/LdapUserInfo.tsx new file mode 100644 index 000000000000..3aed200c4df8 --- /dev/null +++ b/public/app/features/admin/ldap/LdapUserInfo.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; +import { LdapUserMappingInfo } from './LdapUserMappingInfo'; +import { LdapUserPermissions } from './LdapUserPermissions'; +import { LdapUserGroups } from './LdapUserGroups'; +import { LdapUserTeams } from './LdapUserTeams'; +import { LdapUser } from 'app/types'; + +interface Props { + ldapUser: LdapUser; + showAttributeMapping?: boolean; +} + +export const LdapUserInfo: FC = ({ ldapUser, showAttributeMapping }) => { + return ( + <> + + + {ldapUser.roles && ldapUser.roles.length > 0 && ( + + )} + {ldapUser.teams && ldapUser.teams.length > 0 && ( + + )} + + ); +}; diff --git a/public/app/features/admin/ldap/LdapUserMappingInfo.tsx b/public/app/features/admin/ldap/LdapUserMappingInfo.tsx new file mode 100644 index 000000000000..26998042faff --- /dev/null +++ b/public/app/features/admin/ldap/LdapUserMappingInfo.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { LdapUserInfo } from 'app/types'; + +interface Props { + info: LdapUserInfo; + showAttributeMapping?: boolean; +} + +export const LdapUserMappingInfo: FC = ({ info, showAttributeMapping }) => { + return ( +
+
+ + + + + {showAttributeMapping && } + + + + + + + {showAttributeMapping && } + + + + + {showAttributeMapping && } + + + + + {showAttributeMapping && } + + + + + {showAttributeMapping && } + + +
User informationLDAP attribute
First name{info.name.ldapValue}{info.name.cfgAttrValue}
Surname{info.surname.ldapValue}{info.surname.cfgAttrValue}
Username{info.login.ldapValue}{info.login.cfgAttrValue}
Email{info.email.ldapValue}{info.email.cfgAttrValue}
+
+
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapUserPage.tsx b/public/app/features/admin/ldap/LdapUserPage.tsx new file mode 100644 index 000000000000..5a8b99c15042 --- /dev/null +++ b/public/app/features/admin/ldap/LdapUserPage.tsx @@ -0,0 +1,159 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { NavModel } from '@grafana/data'; +import Page from 'app/core/components/Page/Page'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { + AppNotificationSeverity, + LdapError, + LdapUser, + StoreState, + User, + UserSession, + SyncInfo, + LdapUserSyncInfo, +} from 'app/types'; +import { + clearUserError, + loadLdapUserInfo, + revokeSession, + revokeAllSessions, + loadLdapSyncStatus, + syncUser, +} from '../state/actions'; +import { LdapUserInfo } from './LdapUserInfo'; +import { getRouteParamsId } from 'app/core/selectors/location'; +import { UserSessions } from '../UserSessions'; +import { UserInfo } from '../UserInfo'; +import { UserSyncInfo } from '../UserSyncInfo'; + +interface Props { + navModel: NavModel; + userId: number; + user: User; + sessions: UserSession[]; + ldapUser: LdapUser; + userError?: LdapError; + ldapSyncInfo?: SyncInfo; + + loadLdapUserInfo: typeof loadLdapUserInfo; + clearUserError: typeof clearUserError; + loadLdapSyncStatus: typeof loadLdapSyncStatus; + syncUser: typeof syncUser; + revokeSession: typeof revokeSession; + revokeAllSessions: typeof revokeAllSessions; +} + +interface State { + isLoading: boolean; +} + +export class LdapUserPage extends PureComponent { + state = { + isLoading: true, + }; + + async componentDidMount() { + const { userId, loadLdapUserInfo, loadLdapSyncStatus } = this.props; + try { + await loadLdapUserInfo(userId); + await loadLdapSyncStatus(); + } finally { + this.setState({ isLoading: false }); + } + } + + onClearUserError = () => { + this.props.clearUserError(); + }; + + onSyncUser = () => { + const { syncUser, user } = this.props; + if (syncUser && user) { + syncUser(user.id); + } + }; + + onSessionRevoke = (tokenId: number) => { + const { userId, revokeSession } = this.props; + revokeSession(tokenId, userId); + }; + + onAllSessionsRevoke = () => { + const { userId, revokeAllSessions } = this.props; + revokeAllSessions(userId); + }; + + render() { + const { user, ldapUser, userError, navModel, sessions, ldapSyncInfo } = this.props; + const { isLoading } = this.state; + + const userSyncInfo: LdapUserSyncInfo = {}; + if (ldapSyncInfo) { + userSyncInfo.nextSync = ldapSyncInfo.nextSync; + } + if (user) { + userSyncInfo.prevSync = (user as any).updatedAt; + } + + return ( + + +
+ This user is synced via LDAP – all changes must be done in LDAP or mappings. +
+ {userError && userError.title && ( +
+ +
+ )} + + {ldapUser && } + {!ldapUser && user && } + {userSyncInfo && } + + {sessions && ( + + )} +
+
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + userId: getRouteParamsId(state.location), + navModel: getNavModel(state.navIndex, 'global-users'), + user: state.ldapUser.user, + ldapUser: state.ldapUser.ldapUser, + userError: state.ldapUser.userError, + ldapSyncInfo: state.ldapUser.ldapSyncInfo, + sessions: state.ldapUser.sessions, +}); + +const mapDispatchToProps = { + loadLdapUserInfo, + loadLdapSyncStatus, + syncUser, + revokeSession, + revokeAllSessions, + clearUserError, +}; + +export default hot(module)( + connect( + mapStateToProps, + mapDispatchToProps + )(LdapUserPage) +); diff --git a/public/app/features/admin/ldap/LdapUserPermissions.tsx b/public/app/features/admin/ldap/LdapUserPermissions.tsx new file mode 100644 index 000000000000..180c4015912e --- /dev/null +++ b/public/app/features/admin/ldap/LdapUserPermissions.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { LdapPermissions } from 'app/types'; + +interface Props { + permissions: LdapPermissions; +} + +export const LdapUserPermissions: FC = ({ permissions }) => { + return ( +
+
+ + + + + + + + + + + + + + + + +
Permissions
Grafana admin + {permissions.isGrafanaAdmin ? ( + <> + Yes + + ) : ( + 'No' + )} +
Status + {permissions.isDisabled ? ( + <> + Inactive + + ) : ( + <> + Active + + )} +
+
+
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapUserTeams.tsx b/public/app/features/admin/ldap/LdapUserTeams.tsx new file mode 100644 index 000000000000..30ac81b52310 --- /dev/null +++ b/public/app/features/admin/ldap/LdapUserTeams.tsx @@ -0,0 +1,55 @@ +import React, { FC } from 'react'; +import { css } from 'emotion'; +import { Tooltip } from '@grafana/ui'; +import { LdapTeam } from 'app/types'; + +interface Props { + teams: LdapTeam[]; + showAttributeMapping?: boolean; +} + +export const LdapUserTeams: FC = ({ teams, showAttributeMapping }) => { + const items = showAttributeMapping ? teams : teams.filter(item => item.teamName); + const teamColumnClass = showAttributeMapping && 'width-14'; + const noMatchPlaceholderStyle = css` + display: flex; + `; + + return ( +
+
+ + + + + + {showAttributeMapping && } + + + + {items.map((team, index) => { + return ( + + + + {showAttributeMapping && } + + ); + })} + +
OrganisationTeamLDAP
+ {team.orgName || ( +
+ No match + +
+ +
+
+
+ )} +
{team.teamName}{team.groupDN}
+
+
+ ); +}; diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index 21346b92aa7c..9122300cc627 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -30,27 +30,27 @@ - + - + {{user.login}} - + {{user.email}} - + {{user.lastSeenAtAge}} - + diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts new file mode 100644 index 000000000000..93501db91d86 --- /dev/null +++ b/public/app/features/admin/state/actions.ts @@ -0,0 +1,137 @@ +import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux'; +import config from 'app/core/config'; +import { ThunkResult, SyncInfo, LdapUser, LdapConnectionInfo, LdapError, UserSession, User } from 'app/types'; +import { + getUserInfo, + getLdapState, + syncLdapUser, + getUser, + getUserSessions, + revokeUserSession, + revokeAllUserSessions, + getLdapSyncStatus, +} from './apis'; + +// Action types + +export const ldapConnectionInfoLoadedAction = actionCreatorFactory( + 'ldap/CONNECTION_INFO_LOADED' +).create(); +export const ldapSyncStatusLoadedAction = actionCreatorFactory('ldap/SYNC_STATUS_LOADED').create(); +export const userMappingInfoLoadedAction = actionCreatorFactory('ldap/USER_INFO_LOADED').create(); +export const userMappingInfoFailedAction = actionCreatorFactory('ldap/USER_INFO_FAILED').create(); +export const clearUserMappingInfoAction = noPayloadActionCreatorFactory('ldap/CLEAR_USER_MAPPING_INFO').create(); +export const clearUserErrorAction = noPayloadActionCreatorFactory('ldap/CLEAR_USER_ERROR').create(); +export const ldapFailedAction = actionCreatorFactory('ldap/LDAP_FAILED').create(); + +export const userLoadedAction = actionCreatorFactory('USER_LOADED').create(); +export const userSessionsLoadedAction = actionCreatorFactory('USER_SESSIONS_LOADED').create(); +export const userSyncFailedAction = noPayloadActionCreatorFactory('USER_SYNC_FAILED').create(); +export const revokeUserSessionAction = noPayloadActionCreatorFactory('REVOKE_USER_SESSION').create(); +export const revokeAllUserSessionsAction = noPayloadActionCreatorFactory('REVOKE_ALL_USER_SESSIONS').create(); + +// Actions + +export function loadLdapState(): ThunkResult { + return async dispatch => { + try { + const connectionInfo = await getLdapState(); + dispatch(ldapConnectionInfoLoadedAction(connectionInfo)); + } catch (error) { + const ldapError = { + title: error.data.message, + body: error.data.error, + }; + dispatch(ldapFailedAction(ldapError)); + } + }; +} + +export function loadLdapSyncStatus(): ThunkResult { + return async dispatch => { + if (config.buildInfo.isEnterprise) { + // Available only in enterprise + const syncStatus = await getLdapSyncStatus(); + dispatch(ldapSyncStatusLoadedAction(syncStatus)); + } + }; +} + +export function loadUserMapping(username: string): ThunkResult { + return async dispatch => { + try { + const userInfo = await getUserInfo(username); + dispatch(userMappingInfoLoadedAction(userInfo)); + } catch (error) { + const userError = { + title: error.data.message, + body: error.data.error, + }; + dispatch(clearUserMappingInfoAction()); + dispatch(userMappingInfoFailedAction(userError)); + } + }; +} + +export function clearUserError(): ThunkResult { + return dispatch => { + dispatch(clearUserErrorAction()); + }; +} + +export function clearUserMappingInfo(): ThunkResult { + return dispatch => { + dispatch(clearUserErrorAction()); + dispatch(clearUserMappingInfoAction()); + }; +} + +export function syncUser(userId: number): ThunkResult { + return async dispatch => { + try { + await syncLdapUser(userId); + dispatch(loadLdapUserInfo(userId)); + dispatch(loadLdapSyncStatus()); + } catch (error) { + dispatch(userSyncFailedAction()); + } + }; +} + +export function loadLdapUserInfo(userId: number): ThunkResult { + return async dispatch => { + try { + const user = await getUser(userId); + dispatch(userLoadedAction(user)); + dispatch(loadUserSessions(userId)); + dispatch(loadUserMapping(user.login)); + } catch (error) { + const userError = { + title: error.data.message, + body: error.data.error, + }; + dispatch(userMappingInfoFailedAction(userError)); + } + }; +} + +export function loadUserSessions(userId: number): ThunkResult { + return async dispatch => { + const sessions = await getUserSessions(userId); + dispatch(userSessionsLoadedAction(sessions)); + }; +} + +export function revokeSession(tokenId: number, userId: number): ThunkResult { + return async dispatch => { + await revokeUserSession(tokenId, userId); + dispatch(loadUserSessions(userId)); + }; +} + +export function revokeAllSessions(userId: number): ThunkResult { + return async dispatch => { + await revokeAllUserSessions(userId); + dispatch(loadUserSessions(userId)); + }; +} diff --git a/public/app/features/admin/state/apis.ts b/public/app/features/admin/state/apis.ts index 1166fa4dc011..f7194c620863 100644 --- a/public/app/features/admin/state/apis.ts +++ b/public/app/features/admin/state/apis.ts @@ -1,4 +1,6 @@ import { getBackendSrv } from '@grafana/runtime'; +import { dateTime } from '@grafana/data'; +import { LdapUser, LdapConnectionInfo, UserSession, SyncInfo, User } from 'app/types'; export interface ServerStat { name: string; @@ -31,3 +33,64 @@ export const getServerStats = async (): Promise => { throw error; } }; + +export const getLdapState = async (): Promise => { + return await getBackendSrv().get(`/api/admin/ldap/status`); +}; + +export const getLdapSyncStatus = async (): Promise => { + return await getBackendSrv().get(`/api/admin/ldap-sync-status`); +}; + +export const syncLdapUser = async (userId: number) => { + return await getBackendSrv().post(`/api/admin/ldap/sync/${userId}`); +}; + +export const getUserInfo = async (username: string): Promise => { + try { + const response = await getBackendSrv().get(`/api/admin/ldap/${username}`); + const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response; + return { + info: { name, surname, email, login }, + permissions: { isGrafanaAdmin, isDisabled }, + roles, + teams, + }; + } catch (error) { + throw error; + } +}; + +export const getUser = async (id: number): Promise => { + return await getBackendSrv().get('/api/users/' + id); +}; + +export const getUserSessions = async (id: number) => { + const sessions = await getBackendSrv().get('/api/admin/users/' + id + '/auth-tokens'); + sessions.reverse(); + + return sessions.map((session: UserSession) => { + return { + id: session.id, + isActive: session.isActive, + seenAt: dateTime(session.seenAt).fromNow(), + createdAt: dateTime(session.createdAt).format('MMMM DD, YYYY'), + clientIp: session.clientIp, + browser: session.browser, + browserVersion: session.browserVersion, + os: session.os, + osVersion: session.osVersion, + device: session.device, + }; + }); +}; + +export const revokeUserSession = async (tokenId: number, userId: number) => { + return await getBackendSrv().post(`/api/admin/users/${userId}/revoke-auth-token`, { + authTokenId: tokenId, + }); +}; + +export const revokeAllUserSessions = async (userId: number) => { + return await getBackendSrv().post(`/api/admin/users/${userId}/logout`); +}; diff --git a/public/app/features/admin/state/reducers.test.ts b/public/app/features/admin/state/reducers.test.ts new file mode 100644 index 000000000000..2e181742ce61 --- /dev/null +++ b/public/app/features/admin/state/reducers.test.ts @@ -0,0 +1,210 @@ +import { Reducer } from 'redux'; +import { reducerTester } from 'test/core/redux/reducerTester'; +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { ldapReducer, ldapUserReducer } from './reducers'; +import { + ldapConnectionInfoLoadedAction, + ldapSyncStatusLoadedAction, + userMappingInfoLoadedAction, + userMappingInfoFailedAction, + ldapFailedAction, + userLoadedAction, +} from './actions'; +import { LdapState, LdapUserState, LdapUser, User } from 'app/types'; + +const makeInitialLdapState = (): LdapState => ({ + connectionInfo: [], + syncInfo: null, + user: null, + ldapError: null, + connectionError: null, + userError: null, +}); + +const makeInitialLdapUserState = (): LdapUserState => ({ + user: null, + ldapUser: null, + ldapSyncInfo: null, + sessions: [], +}); + +const getTestUserMapping = (): LdapUser => ({ + info: { + email: { cfgAttrValue: 'mail', ldapValue: 'user@localhost' }, + name: { cfgAttrValue: 'givenName', ldapValue: 'User' }, + surname: { cfgAttrValue: 'sn', ldapValue: '' }, + login: { cfgAttrValue: 'cn', ldapValue: 'user' }, + }, + permissions: { + isGrafanaAdmin: false, + isDisabled: false, + }, + roles: [], + teams: [], +}); + +const getTestUser = (): User => ({ + id: 1, + email: 'user@localhost', + login: 'user', + name: 'User', + avatarUrl: '', + label: '', +}); + +describe('LDAP page reducer', () => { + describe('When page loaded', () => { + describe('When connection info loaded', () => { + it('should set connection info and clear error', () => { + const initalState = { + ...makeInitialLdapState(), + }; + + reducerTester() + .givenReducer(ldapReducer as Reducer>, initalState) + .whenActionIsDispatched( + ldapConnectionInfoLoadedAction([ + { + available: true, + host: 'localhost', + port: 389, + error: null, + }, + ]) + ) + .thenStateShouldEqual({ + ...makeInitialLdapState(), + connectionInfo: [ + { + available: true, + host: 'localhost', + port: 389, + error: null, + }, + ], + ldapError: null, + }); + }); + }); + + describe('When connection failed', () => { + it('should set ldap error', () => { + const initalState = { + ...makeInitialLdapState(), + }; + + reducerTester() + .givenReducer(ldapReducer as Reducer>, initalState) + .whenActionIsDispatched( + ldapFailedAction({ + title: 'LDAP error', + body: 'Failed to connect', + }) + ) + .thenStateShouldEqual({ + ...makeInitialLdapState(), + ldapError: { + title: 'LDAP error', + body: 'Failed to connect', + }, + }); + }); + }); + + describe('When LDAP sync status loaded', () => { + it('should set sync info', () => { + const initalState = { + ...makeInitialLdapState(), + }; + + reducerTester() + .givenReducer(ldapReducer as Reducer>, initalState) + .whenActionIsDispatched( + ldapSyncStatusLoadedAction({ + enabled: true, + schedule: '0 0 * * * *', + nextSync: '2019-01-01T12:00:00Z', + }) + ) + .thenStateShouldEqual({ + ...makeInitialLdapState(), + syncInfo: { + enabled: true, + schedule: '0 0 * * * *', + nextSync: '2019-01-01T12:00:00Z', + }, + }); + }); + }); + }); + + describe('When user mapping info loaded', () => { + it('should set sync info and clear user error', () => { + const initalState = { + ...makeInitialLdapState(), + userError: { + title: 'User not found', + body: 'Cannot find user', + }, + }; + + reducerTester() + .givenReducer(ldapReducer as Reducer>, initalState) + .whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping())) + .thenStateShouldEqual({ + ...makeInitialLdapState(), + user: getTestUserMapping(), + userError: null, + }); + }); + }); + + describe('When user not found', () => { + it('should set user error and clear user info', () => { + const initalState = { + ...makeInitialLdapState(), + user: getTestUserMapping(), + }; + + reducerTester() + .givenReducer(ldapReducer as Reducer>, initalState) + .whenActionIsDispatched( + userMappingInfoFailedAction({ + title: 'User not found', + body: 'Cannot find user', + }) + ) + .thenStateShouldEqual({ + ...makeInitialLdapState(), + user: null, + userError: { + title: 'User not found', + body: 'Cannot find user', + }, + }); + }); + }); +}); + +describe('Edit LDAP user page reducer', () => { + describe('When user loaded', () => { + it('should set user and clear user error', () => { + const initalState = { + ...makeInitialLdapUserState(), + userError: { + title: 'User not found', + body: 'Cannot find user', + }, + }; + + reducerTester() + .givenReducer(ldapUserReducer as Reducer>, initalState) + .whenActionIsDispatched(userLoadedAction(getTestUser())) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + user: getTestUser(), + userError: null, + }); + }); + }); +}); diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts new file mode 100644 index 000000000000..a8353379d33b --- /dev/null +++ b/public/app/features/admin/state/reducers.ts @@ -0,0 +1,135 @@ +import { reducerFactory } from 'app/core/redux'; +import { LdapState, LdapUserState } from 'app/types'; +import { + ldapConnectionInfoLoadedAction, + ldapFailedAction, + userMappingInfoLoadedAction, + userMappingInfoFailedAction, + clearUserErrorAction, + userLoadedAction, + userSessionsLoadedAction, + ldapSyncStatusLoadedAction, + clearUserMappingInfoAction, +} from './actions'; + +const initialLdapState: LdapState = { + connectionInfo: [], + syncInfo: null, + user: null, + connectionError: null, + userError: null, +}; + +const initialLdapUserState: LdapUserState = { + user: null, + ldapUser: null, + ldapSyncInfo: null, + sessions: [], +}; + +export const ldapReducer = reducerFactory(initialLdapState) + .addMapper({ + filter: ldapConnectionInfoLoadedAction, + mapper: (state, action) => ({ + ...state, + ldapError: null, + connectionInfo: action.payload, + }), + }) + .addMapper({ + filter: ldapFailedAction, + mapper: (state, action) => ({ + ...state, + ldapError: action.payload, + }), + }) + .addMapper({ + filter: ldapSyncStatusLoadedAction, + mapper: (state, action) => ({ + ...state, + syncInfo: action.payload, + }), + }) + .addMapper({ + filter: userMappingInfoLoadedAction, + mapper: (state, action) => ({ + ...state, + user: action.payload, + userError: null, + }), + }) + .addMapper({ + filter: userMappingInfoFailedAction, + mapper: (state, action) => ({ + ...state, + user: null, + userError: action.payload, + }), + }) + .addMapper({ + filter: clearUserMappingInfoAction, + mapper: (state, action) => ({ + ...state, + user: null, + }), + }) + .addMapper({ + filter: clearUserErrorAction, + mapper: state => ({ + ...state, + userError: null, + }), + }) + .create(); + +export const ldapUserReducer = reducerFactory(initialLdapUserState) + .addMapper({ + filter: userMappingInfoLoadedAction, + mapper: (state, action) => ({ + ...state, + ldapUser: action.payload, + }), + }) + .addMapper({ + filter: userMappingInfoFailedAction, + mapper: (state, action) => ({ + ...state, + ldapUser: null, + userError: action.payload, + }), + }) + .addMapper({ + filter: clearUserErrorAction, + mapper: state => ({ + ...state, + userError: null, + }), + }) + .addMapper({ + filter: ldapSyncStatusLoadedAction, + mapper: (state, action) => ({ + ...state, + ldapSyncInfo: action.payload, + }), + }) + .addMapper({ + filter: userLoadedAction, + mapper: (state, action) => ({ + ...state, + user: action.payload, + userError: null, + }), + }) + .addMapper({ + filter: userSessionsLoadedAction, + mapper: (state, action) => ({ + ...state, + sessions: action.payload, + }), + }) + .create(); + +export default { + ldap: ldapReducer, + ldapUser: ldapUserReducer, +}; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 6a1f8d9d15d5..1c7f7821f2e4 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -6,6 +6,8 @@ import { applyRouteRegistrationHandlers } from './registry'; import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl'; import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl'; import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl'; +import LdapPage from 'app/features/admin/ldap/LdapPage'; +import LdapUserPage from 'app/features/admin/ldap/LdapUserPage'; import config from 'app/core/config'; import { route, ILocationProvider } from 'angular'; @@ -257,6 +259,12 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati templateUrl: 'public/app/features/admin/partials/edit_user.html', controller: 'AdminEditUserCtrl', }) + .when('/admin/users/ldap/edit/:id', { + template: '', + resolve: { + component: () => LdapUserPage, + }, + }) .when('/admin/orgs', { templateUrl: 'public/app/features/admin/partials/orgs.html', controller: 'AdminListOrgsCtrl', @@ -273,6 +281,12 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati component: () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats'), }, }) + .when('/admin/ldap', { + template: '', + resolve: { + component: () => LdapPage, + }, + }) // LOGIN / SIGNUP .when('/login', { template: '', diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index f549f81bcc8c..7c210cb81eba 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -13,6 +13,7 @@ import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; +import ldapReducers from 'app/features/admin/state/reducers'; import { setStore } from './store'; import { StoreState } from 'app/types/store'; import { toggleLogActionsMiddleware } from 'app/core/middlewares/application'; @@ -30,6 +31,7 @@ const rootReducers = { ...usersReducers, ...userReducers, ...organizationReducers, + ...ldapReducers, }; export function addRootReducer(reducers: any) { diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 6da9d859f5c7..a47f11230249 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -13,3 +13,4 @@ export * from './appNotifications'; export * from './search'; export * from './explore'; export * from './store'; +export * from './ldap'; diff --git a/public/app/types/ldap.ts b/public/app/types/ldap.ts new file mode 100644 index 000000000000..cebb32958da9 --- /dev/null +++ b/public/app/types/ldap.ts @@ -0,0 +1,95 @@ +import { User, UserSession } from 'app/types'; + +interface LdapMapping { + cfgAttrValue: string; + ldapValue: string; +} + +export interface LdapError { + title: string; + body: string; +} + +export interface SyncInfo { + enabled: boolean; + schedule: string; + nextSync: string; + prevSync?: SyncResult; +} + +export interface LdapUserSyncInfo { + nextSync?: string; + prevSync?: string; + status?: string; +} + +export interface SyncResult { + started: string; + elapsed: string; + UpdatedUserIds: number[]; + MissingUserIds: number[]; + FailedUsers?: FailedUser[]; +} + +export interface FailedUser { + Login: string; + Error: string; +} + +export interface LdapRole { + orgId: number; + orgName: string; + orgRole: string; + groupDN: string; +} + +export interface LdapTeam { + orgName: string; + teamName: string; + groupDN: string; +} + +export interface LdapUserInfo { + name: LdapMapping; + surname: LdapMapping; + email: LdapMapping; + login: LdapMapping; +} + +export interface LdapPermissions { + isGrafanaAdmin: boolean; + isDisabled: boolean; +} + +export interface LdapUser { + info: LdapUserInfo; + permissions: LdapPermissions; + roles: LdapRole[]; + teams: LdapTeam[]; +} + +export interface LdapServerInfo { + available: boolean; + host: string; + port: number; + error: string; +} + +export type LdapConnectionInfo = LdapServerInfo[]; + +export interface LdapState { + connectionInfo: LdapConnectionInfo; + user?: LdapUser; + syncInfo?: SyncInfo; + connectionError?: LdapError; + userError?: LdapError; + ldapError?: LdapError; +} + +export interface LdapUserState { + user?: User; + ldapUser?: LdapUser; + ldapSyncInfo?: SyncInfo; + sessions?: UserSession[]; + userError?: LdapError; +} diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 26e2749cb5a3..d39e77f46f1c 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -14,6 +14,7 @@ import { AppNotificationsState } from './appNotifications'; import { PluginsState } from './plugins'; import { NavIndex } from '@grafana/data'; import { ApplicationState } from './application'; +import { LdapState, LdapUserState } from './ldap'; export interface StoreState { navIndex: NavIndex; @@ -31,6 +32,8 @@ export interface StoreState { user: UserState; plugins: PluginsState; application: ApplicationState; + ldap: LdapState; + ldapUser: LdapUserState; } /* diff --git a/public/sass/components/_alerts.scss b/public/sass/components/_alerts.scss index 56e7c8e34449..628d0718113d 100644 --- a/public/sass/components/_alerts.scss +++ b/public/sass/components/_alerts.scss @@ -79,3 +79,7 @@ .alert-body { flex-grow: 1; } + +.alert-icon-on-top { + align-items: flex-start; +} From 85e128feded0820cf93145dc1c681c21ebcdc4f0 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon, 16 Sep 2019 18:55:52 +0200 Subject: [PATCH 63/87] Explore: No logs should show an empty graph (#19132) * Explore: add no data points text to graph * Explore: add console log for error (accidentaly removed) * Explore: refactor, created getYAxes method for better readability * Explore: remove unnecessary console.log * Explore: fix getYAxes method to return value * Graph: Simplify warning from no data points to No data --- .../grafana-ui/src/components/Graph/Graph.tsx | 42 ++++++++++++------- public/app/plugins/panel/graph/module.ts | 6 +-- .../panel/graph/specs/graph_ctrl.test.ts | 4 +- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/packages/grafana-ui/src/components/Graph/Graph.tsx b/packages/grafana-ui/src/components/Graph/Graph.tsx index 942cbc07b61e..8c14694fcb88 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.tsx +++ b/packages/grafana-ui/src/components/Graph/Graph.tsx @@ -54,6 +54,27 @@ export class Graph extends PureComponent { } }; + getYAxes(series: GraphSeriesXY[]) { + if (series.length === 0) { + return [{ show: true, min: -1, max: 1 }]; + } + return uniqBy( + series.map(s => { + const index = s.yAxis ? s.yAxis.index : 1; + const min = s.yAxis && !isNaN(s.yAxis.min as number) ? s.yAxis.min : null; + const tickDecimals = s.yAxis && !isNaN(s.yAxis.tickDecimals as number) ? s.yAxis.tickDecimals : null; + return { + show: true, + index, + position: index === 1 ? 'left' : 'right', + min, + tickDecimals, + }; + }), + yAxisConfig => yAxisConfig.index + ); + } + draw() { if (this.element === null) { return; @@ -79,22 +100,8 @@ export class Graph extends PureComponent { const ticks = width / 100; const min = timeRange.from.valueOf(); const max = timeRange.to.valueOf(); - const yaxes = uniqBy( - series.map(s => { - const index = s.yAxis ? s.yAxis.index : 1; - const min = s.yAxis && !isNaN(s.yAxis.min as number) ? s.yAxis.min : null; - const tickDecimals = s.yAxis && !isNaN(s.yAxis.tickDecimals as number) ? s.yAxis.tickDecimals : null; + const yaxes = this.getYAxes(series); - return { - show: true, - index, - position: index === 1 ? 'left' : 'right', - min, - tickDecimals, - }; - }), - yAxisConfig => yAxisConfig.index - ); const flotOptions: any = { legend: { show: false, @@ -122,6 +129,7 @@ export class Graph extends PureComponent { shadowSize: 0, }, xaxis: { + show: true, mode: 'time', min: min, max: max, @@ -157,10 +165,12 @@ export class Graph extends PureComponent { } render() { - const { height } = this.props; + const { height, series } = this.props; + const noDataToBeDisplayed = series.length === 0; return (
(this.element = e)} style={{ height }} /> + {noDataToBeDisplayed &&
No data
}
); } diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 0992bed53524..571d3573198f 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -225,14 +225,14 @@ class GraphCtrl extends MetricsPanelCtrl { if (datapointsCount === 0) { this.dataWarning = { - title: 'No data points', - tip: 'No datapoints returned from data query', + title: 'No data', + tip: 'No data returned from query', }; } else { for (const series of this.seriesList) { if (series.isOutsideRange) { this.dataWarning = { - title: 'Data points outside time range', + title: 'Data outside time range', tip: 'Can be caused by timezone mismatch or missing time filter in query', }; break; diff --git a/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts b/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts index 4d030800d1c2..c470d53ac5dc 100644 --- a/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts +++ b/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts @@ -58,7 +58,7 @@ describe('GraphCtrl', () => { }); it('should set datapointsOutside', () => { - expect(ctx.ctrl.dataWarning.title).toBe('Data points outside time range'); + expect(ctx.ctrl.dataWarning.title).toBe('Data outside time range'); }); }); @@ -94,7 +94,7 @@ describe('GraphCtrl', () => { }); it('should set datapointsCount warning', () => { - expect(ctx.ctrl.dataWarning.title).toBe('No data points'); + expect(ctx.ctrl.dataWarning.title).toBe('No data'); }); }); }); From 015ab370b09d8019ab2df115a53375b9bb47d82f Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 16 Sep 2019 20:59:22 -0700 Subject: [PATCH 64/87] Alerts: show a warning/error if transformations are configured (#19027) --- .../app/core/components/AlertBox/AlertBox.tsx | 6 ++++++ public/app/features/alerting/AlertTab.tsx | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx index c9e19920fbbe..852a1e51cfb5 100644 --- a/public/app/core/components/AlertBox/AlertBox.tsx +++ b/public/app/core/components/AlertBox/AlertBox.tsx @@ -15,6 +15,12 @@ function getIconFromSeverity(severity: AppNotificationSeverity): string { case AppNotificationSeverity.Error: { return 'fa fa-exclamation-triangle'; } + case AppNotificationSeverity.Warning: { + return 'fa fa-exclamation-triangle'; + } + case AppNotificationSeverity.Info: { + return 'fa fa-info-circle'; + } case AppNotificationSeverity.Success: { return 'fa fa-check'; } diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx index 43857304cbfc..91fe65c0b9cd 100644 --- a/public/app/features/alerting/AlertTab.tsx +++ b/public/app/features/alerting/AlertTab.tsx @@ -15,6 +15,8 @@ import 'app/features/alerting/AlertTabCtrl'; import { DashboardModel } from '../dashboard/state/DashboardModel'; import { PanelModel } from '../dashboard/state/PanelModel'; import { TestRuleResult } from './TestRuleResult'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; +import { AppNotificationSeverity } from 'app/types'; interface Props { angularPanel?: AngularComponent; @@ -127,7 +129,16 @@ export class AlertTab extends PureComponent { }; render() { - const { alert } = this.props.panel; + const { alert, transformations } = this.props.panel; + const hasTransformations = transformations && transformations.length; + + if (!alert && hasTransformations) { + return ( + + + + ); + } const toolbarItems = alert ? [this.stateHistory(), this.testRule(), this.deleteAlert()] : []; @@ -141,6 +152,13 @@ export class AlertTab extends PureComponent { return ( <> + {alert && hasTransformations && ( + + )} +
(this.element = element)} /> {!alert && } From 383bdb160fb7df29f7b2e08a7a5885b46f35bfed Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 16 Sep 2019 21:48:37 -0700 Subject: [PATCH 65/87] Chore: fix prettier error after github suggestions commit (#19150) --- public/app/features/alerting/AlertTab.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx index 91fe65c0b9cd..c7e880c7dd4d 100644 --- a/public/app/features/alerting/AlertTab.tsx +++ b/public/app/features/alerting/AlertTab.tsx @@ -135,7 +135,10 @@ export class AlertTab extends PureComponent { if (!alert && hasTransformations) { return ( - + ); } From 330cd597ecfb937f1604eadb62efcaba379d58ae Mon Sep 17 00:00:00 2001 From: lzd <24379844+lzdw@users.noreply.github.com> Date: Tue, 17 Sep 2019 13:27:21 +0800 Subject: [PATCH 66/87] Login: fix Footer to be visible (#19147) --- public/sass/components/_footer.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/public/sass/components/_footer.scss b/public/sass/components/_footer.scss index 3579171b2bbe..a2c5ef60ce8d 100644 --- a/public/sass/components/_footer.scss +++ b/public/sass/components/_footer.scss @@ -49,6 +49,7 @@ // Keeping footer inside the graphic on Login screen .login-page { .footer { + display: block; bottom: $spacer; position: absolute; padding: $space-md 0 $space-md 0; From 5c0f424d1bfe1f28c4a389caac9a9eef5531b452 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 08:23:47 +0200 Subject: [PATCH 67/87] grafana/data: Reorganise code (#19136) * Organise data frame and vectors code * Organise transformations * Move dataframe utils to dataframe dir * Organise datetime utils * Organise text utils * Organise logs utils * Revert "Organise logs utils" This reverts commit c24115c75558236770dfa2f151e81f71bcd9ddef. * registry -> Registry * Transformations reorg --- .../src/dataframe/CircularDataFrame.ts | 22 ++ .../DataFrameView.test.ts} | 8 +- .../DataFrameView.ts} | 3 +- .../FieldCache.test.ts} | 100 +----- .../grafana-data/src/dataframe/FieldCache.ts | 78 ++++ .../src/dataframe/MutableDataFrame.test.ts | 66 ++++ .../MutableDataFrame.ts} | 133 +------ packages/grafana-data/src/dataframe/index.ts | 5 + .../processDataFrame.test.ts | 4 +- .../{utils => dataframe}/processDataFrame.ts | 9 +- .../src/{utils => datetime}/datemath.test.ts | 2 +- .../src/{utils => datetime}/datemath.ts | 2 +- packages/grafana-data/src/datetime/index.ts | 5 + .../src/{utils => datetime}/moment_wrapper.ts | 0 .../src/{utils => datetime}/rangeutil.ts | 2 +- packages/grafana-data/src/index.ts | 9 +- packages/grafana-data/src/text/index.ts | 3 + .../src/{utils => text}/markdown.test.ts | 0 .../src/{utils => text}/markdown.ts | 0 .../src/{utils => text}/string.test.ts | 0 .../src/{utils => text}/string.ts | 0 .../src/{utils => text}/text.test.ts | 0 .../grafana-data/src/{utils => text}/text.ts | 0 .../fieldReducer.test.ts | 6 +- .../fieldReducer.ts | 4 +- .../grafana-data/src/transformations/index.ts | 7 + .../matchers => transformations}/matchers.ts | 35 +- .../matchers/fieldTypeMatcher.test.ts | 4 +- .../matchers/fieldTypeMatcher.ts | 2 +- .../matchers/ids.ts | 0 .../matchers/matchers.test.ts | 2 +- .../matchers/nameMatcher.test.ts | 4 +- .../matchers/nameMatcher.ts | 4 +- .../matchers/predicates.test.ts | 3 +- .../matchers/predicates.ts | 11 +- .../matchers/refIdMatcher.ts | 2 +- .../transformers.test.ts | 14 +- .../transformers.ts | 36 +- .../__snapshots__/reduce.test.ts.snap | 0 .../transformers/append.test.ts | 7 +- .../transformers/append.ts | 4 +- .../transformers/filter.test.ts | 6 +- .../transformers/filter.ts | 6 +- .../transformers/filterByName.test.ts | 5 +- .../transformers/filterByName.ts | 4 +- .../transformers/ids.ts | 0 .../transformers/noop.ts | 2 +- .../transformers/reduce.test.ts | 4 +- .../transformers/reduce.ts | 12 +- packages/grafana-data/src/types/dataFrame.ts | 22 +- packages/grafana-data/src/types/index.ts | 1 + packages/grafana-data/src/types/time.ts | 2 +- .../grafana-data/src/types/transformations.ts | 32 ++ packages/grafana-data/src/types/vector.ts | 40 +++ .../src/utils/{registry.ts => Registry.ts} | 0 packages/grafana-data/src/utils/csv.test.ts | 4 +- packages/grafana-data/src/utils/csv.ts | 4 +- .../grafana-data/src/utils/fieldParser.ts | 28 ++ packages/grafana-data/src/utils/index.ts | 21 +- packages/grafana-data/src/utils/logs.ts | 2 +- packages/grafana-data/src/utils/vector.ts | 338 ------------------ .../src/vector/AppendedVectors.test.ts | 23 ++ .../src/vector/AppendedVectors.ts | 73 ++++ .../grafana-data/src/vector/ArrayVector.ts | 37 ++ .../CircularVector.test.ts} | 50 +-- .../grafana-data/src/vector/CircularVector.ts | 138 +++++++ .../src/vector/ConstantVector.test.ts | 17 + .../grafana-data/src/vector/ConstantVector.ts | 22 ++ .../src/vector/ScaledVector.test.ts | 15 + .../grafana-data/src/vector/ScaledVector.ts | 22 ++ .../grafana-data/src/vector/SortedVector.ts | 25 ++ packages/grafana-data/src/vector/index.ts | 6 + .../grafana-data/src/vector/vectorToArray.ts | 9 + .../FilterByNameTransformerEditor.tsx | 4 +- .../ReduceTransformerEditor.tsx | 5 +- .../explore/utils/ResultProcessor.test.ts | 2 +- 76 files changed, 811 insertions(+), 766 deletions(-) create mode 100644 packages/grafana-data/src/dataframe/CircularDataFrame.ts rename packages/grafana-data/src/{utils/dataFrameView.test.ts => dataframe/DataFrameView.test.ts} (90%) rename packages/grafana-data/src/{utils/dataFrameView.ts => dataframe/DataFrameView.ts} (95%) rename packages/grafana-data/src/{utils/dataFrameHelper.test.ts => dataframe/FieldCache.test.ts} (56%) create mode 100644 packages/grafana-data/src/dataframe/FieldCache.ts create mode 100644 packages/grafana-data/src/dataframe/MutableDataFrame.test.ts rename packages/grafana-data/src/{utils/dataFrameHelper.ts => dataframe/MutableDataFrame.ts} (66%) create mode 100644 packages/grafana-data/src/dataframe/index.ts rename packages/grafana-data/src/{utils => dataframe}/processDataFrame.test.ts (98%) rename packages/grafana-data/src/{utils => dataframe}/processDataFrame.ts (97%) rename packages/grafana-data/src/{utils => datetime}/datemath.test.ts (98%) rename packages/grafana-data/src/{utils => datetime}/datemath.ts (99%) create mode 100644 packages/grafana-data/src/datetime/index.ts rename packages/grafana-data/src/{utils => datetime}/moment_wrapper.ts (100%) rename packages/grafana-data/src/{utils => datetime}/rangeutil.ts (98%) create mode 100644 packages/grafana-data/src/text/index.ts rename packages/grafana-data/src/{utils => text}/markdown.test.ts (100%) rename packages/grafana-data/src/{utils => text}/markdown.ts (100%) rename packages/grafana-data/src/{utils => text}/string.test.ts (100%) rename packages/grafana-data/src/{utils => text}/string.ts (100%) rename packages/grafana-data/src/{utils => text}/text.test.ts (100%) rename packages/grafana-data/src/{utils => text}/text.ts (100%) rename packages/grafana-data/src/{utils => transformations}/fieldReducer.test.ts (96%) rename packages/grafana-data/src/{utils => transformations}/fieldReducer.ts (98%) create mode 100644 packages/grafana-data/src/transformations/index.ts rename packages/grafana-data/src/{utils/matchers => transformations}/matchers.ts (55%) rename packages/grafana-data/src/{utils => transformations}/matchers/fieldTypeMatcher.test.ts (84%) rename packages/grafana-data/src/{utils => transformations}/matchers/fieldTypeMatcher.ts (95%) rename packages/grafana-data/src/{utils => transformations}/matchers/ids.ts (100%) rename packages/grafana-data/src/{utils => transformations}/matchers/matchers.test.ts (85%) rename packages/grafana-data/src/{utils => transformations}/matchers/nameMatcher.test.ts (92%) rename packages/grafana-data/src/{utils => transformations}/matchers/nameMatcher.ts (90%) rename packages/grafana-data/src/{utils => transformations}/matchers/predicates.test.ts (92%) rename packages/grafana-data/src/{utils => transformations}/matchers/predicates.ts (97%) rename packages/grafana-data/src/{utils => transformations}/matchers/refIdMatcher.ts (89%) rename packages/grafana-data/src/{utils/transformers => transformations}/transformers.test.ts (62%) rename packages/grafana-data/src/{utils/transformers => transformations}/transformers.ts (71%) rename packages/grafana-data/src/{utils => transformations}/transformers/__snapshots__/reduce.test.ts.snap (100%) rename packages/grafana-data/src/{utils => transformations}/transformers/append.test.ts (79%) rename packages/grafana-data/src/{utils => transformations}/transformers/append.ts (91%) rename packages/grafana-data/src/{utils => transformations}/transformers/filter.test.ts (87%) rename packages/grafana-data/src/{utils => transformations}/transformers/filter.ts (95%) rename packages/grafana-data/src/{utils => transformations}/transformers/filterByName.test.ts (91%) rename packages/grafana-data/src/{utils => transformations}/transformers/filterByName.ts (94%) rename packages/grafana-data/src/{utils => transformations}/transformers/ids.ts (100%) rename packages/grafana-data/src/{utils => transformations}/transformers/noop.ts (90%) rename packages/grafana-data/src/{utils => transformations}/transformers/reduce.test.ts (83%) rename packages/grafana-data/src/{utils => transformations}/transformers/reduce.ts (89%) create mode 100644 packages/grafana-data/src/types/transformations.ts create mode 100644 packages/grafana-data/src/types/vector.ts rename packages/grafana-data/src/utils/{registry.ts => Registry.ts} (100%) create mode 100644 packages/grafana-data/src/utils/fieldParser.ts delete mode 100644 packages/grafana-data/src/utils/vector.ts create mode 100644 packages/grafana-data/src/vector/AppendedVectors.test.ts create mode 100644 packages/grafana-data/src/vector/AppendedVectors.ts create mode 100644 packages/grafana-data/src/vector/ArrayVector.ts rename packages/grafana-data/src/{utils/vector.test.ts => vector/CircularVector.test.ts} (65%) create mode 100644 packages/grafana-data/src/vector/CircularVector.ts create mode 100644 packages/grafana-data/src/vector/ConstantVector.test.ts create mode 100644 packages/grafana-data/src/vector/ConstantVector.ts create mode 100644 packages/grafana-data/src/vector/ScaledVector.test.ts create mode 100644 packages/grafana-data/src/vector/ScaledVector.ts create mode 100644 packages/grafana-data/src/vector/SortedVector.ts create mode 100644 packages/grafana-data/src/vector/index.ts create mode 100644 packages/grafana-data/src/vector/vectorToArray.ts diff --git a/packages/grafana-data/src/dataframe/CircularDataFrame.ts b/packages/grafana-data/src/dataframe/CircularDataFrame.ts new file mode 100644 index 000000000000..890be6c2f50b --- /dev/null +++ b/packages/grafana-data/src/dataframe/CircularDataFrame.ts @@ -0,0 +1,22 @@ +import { MutableDataFrame } from './MutableDataFrame'; +import { CircularVector } from '../vector/CircularVector'; + +interface CircularOptions { + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * This dataframe can have values constantly added, and will never + * exceed the given capacity + */ +export class CircularDataFrame extends MutableDataFrame { + constructor(options: CircularOptions) { + super(undefined, (buffer?: any[]) => { + return new CircularVector({ + ...options, + buffer, + }); + }); + } +} diff --git a/packages/grafana-data/src/utils/dataFrameView.test.ts b/packages/grafana-data/src/dataframe/DataFrameView.test.ts similarity index 90% rename from packages/grafana-data/src/utils/dataFrameView.test.ts rename to packages/grafana-data/src/dataframe/DataFrameView.test.ts index 8755cfc827a9..eb182e60c396 100644 --- a/packages/grafana-data/src/utils/dataFrameView.test.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.test.ts @@ -1,7 +1,7 @@ -import { FieldType, DataFrameDTO } from '../types/index'; -import { MutableDataFrame } from './dataFrameHelper'; -import { DataFrameView } from './dataFrameView'; -import { DateTime } from './moment_wrapper'; +import { FieldType, DataFrameDTO } from '../types/dataFrame'; +import { DateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; +import { DataFrameView } from './DataFrameView'; interface MySpecialObject { time: DateTime; diff --git a/packages/grafana-data/src/utils/dataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts similarity index 95% rename from packages/grafana-data/src/utils/dataFrameView.ts rename to packages/grafana-data/src/dataframe/DataFrameView.ts index 1cf833e9dedc..cbb0106183c1 100644 --- a/packages/grafana-data/src/utils/dataFrameView.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -1,4 +1,5 @@ -import { DataFrame, Vector } from '../types/index'; +import { Vector } from '../types/vector'; +import { DataFrame } from '../types/dataFrame'; /** * This abstraction will present the contents of a DataFrame as if diff --git a/packages/grafana-data/src/utils/dataFrameHelper.test.ts b/packages/grafana-data/src/dataframe/FieldCache.test.ts similarity index 56% rename from packages/grafana-data/src/utils/dataFrameHelper.test.ts rename to packages/grafana-data/src/dataframe/FieldCache.test.ts index 5555c09cd401..d38946bd712f 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.test.ts +++ b/packages/grafana-data/src/dataframe/FieldCache.test.ts @@ -1,30 +1,7 @@ -import { DataFrameDTO, FieldType } from '../types'; -import { FieldCache, MutableDataFrame } from './dataFrameHelper'; +import { FieldCache } from './FieldCache'; +import { FieldType } from '../types/dataFrame'; import { toDataFrame } from './processDataFrame'; -describe('dataFrameHelper', () => { - const frame = toDataFrame({ - fields: [ - { name: 'time', type: FieldType.time, values: [100, 200, 300] }, - { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, - { name: 'value', type: FieldType.number, values: [1, 2, 3] }, - { name: 'value', type: FieldType.number, values: [4, 5, 6] }, - ], - }); - const ext = new FieldCache(frame); - - it('should get the first field with a duplicate name', () => { - const field = ext.getFieldByName('value'); - expect(field!.name).toEqual('value'); - expect(field!.values.toJSON()).toEqual([1, 2, 3]); - }); - - it('should return index of the field', () => { - const field = ext.getFirstFieldOfType(FieldType.number); - expect(field!.index).toEqual(2); - }); -}); - describe('FieldCache', () => { it('when creating a new FieldCache from fields should be able to query cache', () => { const frame = toDataFrame({ @@ -90,68 +67,27 @@ describe('FieldCache', () => { expect(fieldCache.getFieldByName('undefined')!.name).toEqual(expectedFieldNames[5]); expect(fieldCache.getFieldByName('null')).toBeUndefined(); }); -}); - -describe('reverse', () => { - describe('when called with a DataFrame', () => { - it('then it should reverse the order of values in all fields', () => { - const frame: DataFrameDTO = { - fields: [ - { name: 'time', type: FieldType.time, values: [100, 200, 300] }, - { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, - { name: 'value', type: FieldType.number, values: [1, 2, 3] }, - ], - }; - - const helper = new MutableDataFrame(frame); - - expect(helper.values.time.toArray()).toEqual([100, 200, 300]); - expect(helper.values.name.toArray()).toEqual(['a', 'b', 'c']); - expect(helper.values.value.toArray()).toEqual([1, 2, 3]); - - helper.reverse(); - - expect(helper.values.time.toArray()).toEqual([300, 200, 100]); - expect(helper.values.name.toArray()).toEqual(['c', 'b', 'a']); - expect(helper.values.value.toArray()).toEqual([3, 2, 1]); - }); - }); -}); -describe('Apending DataFrame', () => { - it('Should append values', () => { - const dto: DataFrameDTO = { + describe('field retrieval', () => { + const frame = toDataFrame({ fields: [ - { name: 'time', type: FieldType.time, values: [100] }, - { name: 'name', type: FieldType.string, values: ['a', 'b'] }, + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [4, 5, 6] }, ], - }; - - const frame = new MutableDataFrame(dto); - expect(frame.values.time.toArray()).toEqual([100, null, null]); - - // Set a value on the second row - frame.set(1, { time: 200, name: 'BB', value: 20 }); - expect(frame.toArray()).toEqual([ - { time: 100, name: 'a', value: 1 }, // 1 - { time: 200, name: 'BB', value: 20 }, // 2 - { time: null, name: null, value: 3 }, // 3 - ]); + }); + const ext = new FieldCache(frame); - // Set a value on the second row - frame.add({ value2: 'XXX' }, true); - expect(frame.toArray()).toEqual([ - { time: 100, name: 'a', value: 1, value2: null }, // 1 - { time: 200, name: 'BB', value: 20, value2: null }, // 2 - { time: null, name: null, value: 3, value2: null }, // 3 - { time: null, name: null, value: null, value2: 'XXX' }, // 4 - ]); + it('should get the first field with a duplicate name', () => { + const field = ext.getFieldByName('value'); + expect(field!.name).toEqual('value'); + expect(field!.values.toJSON()).toEqual([1, 2, 3]); + }); - // Make sure length survives a spread operator - const keys = Object.keys(frame); - const copy = { ...frame } as any; - expect(keys).toContain('length'); - expect(copy.length).toEqual(frame.length); + it('should return index of the field', () => { + const field = ext.getFirstFieldOfType(FieldType.number); + expect(field!.index).toEqual(2); + }); }); }); diff --git a/packages/grafana-data/src/dataframe/FieldCache.ts b/packages/grafana-data/src/dataframe/FieldCache.ts new file mode 100644 index 000000000000..07f6941e21de --- /dev/null +++ b/packages/grafana-data/src/dataframe/FieldCache.ts @@ -0,0 +1,78 @@ +import { Field, DataFrame, FieldType, guessFieldTypeForField } from '../index'; + +interface FieldWithIndex extends Field { + index: number; +} + +export class FieldCache { + fields: FieldWithIndex[] = []; + + private fieldByName: { [key: string]: FieldWithIndex } = {}; + private fieldByType: { [key: string]: FieldWithIndex[] } = {}; + + constructor(data: DataFrame) { + this.fields = data.fields.map((field, idx) => ({ + ...field, + index: idx, + })); + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + // Make sure it has a type + if (field.type === FieldType.other) { + const t = guessFieldTypeForField(field); + if (t) { + field.type = t; + } + } + if (!this.fieldByType[field.type]) { + this.fieldByType[field.type] = []; + } + this.fieldByType[field.type].push({ + ...field, + index: i, + }); + + if (this.fieldByName[field.name]) { + console.warn('Duplicate field names in DataFrame: ', field.name); + } else { + this.fieldByName[field.name] = { ...field, index: i }; + } + } + } + + getFields(type?: FieldType): FieldWithIndex[] { + if (!type) { + return [...this.fields]; // All fields + } + const fields = this.fieldByType[type]; + if (fields) { + return [...fields]; + } + return []; + } + + hasFieldOfType(type: FieldType): boolean { + const types = this.fieldByType[type]; + return types && types.length > 0; + } + + getFirstFieldOfType(type: FieldType): FieldWithIndex | undefined { + const arr = this.fieldByType[type]; + if (arr && arr.length > 0) { + return arr[0]; + } + return undefined; + } + + hasFieldNamed(name: string): boolean { + return !!this.fieldByName[name]; + } + + /** + * Returns the first field with the given name. + */ + getFieldByName(name: string): FieldWithIndex | undefined { + return this.fieldByName[name]; + } +} diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts new file mode 100644 index 000000000000..eb6f8aaee432 --- /dev/null +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts @@ -0,0 +1,66 @@ +import { DataFrameDTO, FieldType } from '../types/dataFrame'; +import { MutableDataFrame } from './MutableDataFrame'; + +describe('Reversing DataFrame', () => { + describe('when called with a DataFrame', () => { + it('then it should reverse the order of values in all fields', () => { + const frame: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const helper = new MutableDataFrame(frame); + + expect(helper.values.time.toArray()).toEqual([100, 200, 300]); + expect(helper.values.name.toArray()).toEqual(['a', 'b', 'c']); + expect(helper.values.value.toArray()).toEqual([1, 2, 3]); + + helper.reverse(); + + expect(helper.values.time.toArray()).toEqual([300, 200, 100]); + expect(helper.values.name.toArray()).toEqual(['c', 'b', 'a']); + expect(helper.values.value.toArray()).toEqual([3, 2, 1]); + }); + }); +}); + +describe('Apending DataFrame', () => { + it('Should append values', () => { + const dto: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100] }, + { name: 'name', type: FieldType.string, values: ['a', 'b'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const frame = new MutableDataFrame(dto); + expect(frame.values.time.toArray()).toEqual([100, null, null]); + + // Set a value on the second row + frame.set(1, { time: 200, name: 'BB', value: 20 }); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1 }, // 1 + { time: 200, name: 'BB', value: 20 }, // 2 + { time: null, name: null, value: 3 }, // 3 + ]); + + // Set a value on the second row + frame.add({ value2: 'XXX' }, true); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1, value2: null }, // 1 + { time: 200, name: 'BB', value: 20, value2: null }, // 2 + { time: null, name: null, value: 3, value2: null }, // 3 + { time: null, name: null, value: null, value2: 'XXX' }, // 4 + ]); + + // Make sure length survives a spread operator + const keys = Object.keys(frame); + const copy = { ...frame } as any; + expect(keys).toContain('length'); + expect(copy.length).toEqual(frame.length); + }); +}); diff --git a/packages/grafana-data/src/utils/dataFrameHelper.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts similarity index 66% rename from packages/grafana-data/src/utils/dataFrameHelper.ts rename to packages/grafana-data/src/dataframe/MutableDataFrame.ts index 53eff25aaa05..9ff9aa0f3aef 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.ts +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -1,111 +1,12 @@ -import { Field, FieldType, DataFrame, Vector, FieldDTO, DataFrameDTO } from '../types/dataFrame'; -import { Labels, QueryResultMeta, KeyValue } from '../types/data'; -import { guessFieldTypeForField, guessFieldTypeFromValue, toDataFrameDTO } from './processDataFrame'; -import { ArrayVector, MutableVector, vectorToArray, CircularVector } from './vector'; +import { Field, DataFrame, DataFrameDTO, FieldDTO, FieldType } from '../types/dataFrame'; +import { KeyValue, QueryResultMeta, Labels } from '../types/data'; +import { guessFieldTypeFromValue, guessFieldTypeForField, toDataFrameDTO } from './processDataFrame'; import isArray from 'lodash/isArray'; import isString from 'lodash/isString'; - -interface FieldWithIndex extends Field { - index: number; -} -export class FieldCache { - fields: FieldWithIndex[] = []; - - private fieldByName: { [key: string]: FieldWithIndex } = {}; - private fieldByType: { [key: string]: FieldWithIndex[] } = {}; - - constructor(data: DataFrame) { - this.fields = data.fields.map((field, idx) => ({ - ...field, - index: idx, - })); - - for (let i = 0; i < data.fields.length; i++) { - const field = data.fields[i]; - // Make sure it has a type - if (field.type === FieldType.other) { - const t = guessFieldTypeForField(field); - if (t) { - field.type = t; - } - } - if (!this.fieldByType[field.type]) { - this.fieldByType[field.type] = []; - } - this.fieldByType[field.type].push({ - ...field, - index: i, - }); - - if (this.fieldByName[field.name]) { - console.warn('Duplicate field names in DataFrame: ', field.name); - } else { - this.fieldByName[field.name] = { ...field, index: i }; - } - } - } - - getFields(type?: FieldType): FieldWithIndex[] { - if (!type) { - return [...this.fields]; // All fields - } - const fields = this.fieldByType[type]; - if (fields) { - return [...fields]; - } - return []; - } - - hasFieldOfType(type: FieldType): boolean { - const types = this.fieldByType[type]; - return types && types.length > 0; - } - - getFirstFieldOfType(type: FieldType): FieldWithIndex | undefined { - const arr = this.fieldByType[type]; - if (arr && arr.length > 0) { - return arr[0]; - } - return undefined; - } - - hasFieldNamed(name: string): boolean { - return !!this.fieldByName[name]; - } - - /** - * Returns the first field with the given name. - */ - getFieldByName(name: string): FieldWithIndex | undefined { - return this.fieldByName[name]; - } -} - -function makeFieldParser(value: any, field: Field): (value: string) => any { - if (!field.type) { - if (field.name === 'time' || field.name === 'Time') { - field.type = FieldType.time; - } else { - field.type = guessFieldTypeFromValue(value); - } - } - - if (field.type === FieldType.number) { - return (value: string) => { - return parseFloat(value); - }; - } - - // Will convert anything that starts with "T" to true - if (field.type === FieldType.boolean) { - return (value: string) => { - return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); - }; - } - - // Just pass the string back - return (value: string) => value; -} +import { makeFieldParser } from '../utils/fieldParser'; +import { MutableVector, Vector } from '../types/vector'; +import { ArrayVector } from '../vector/ArrayVector'; +import { vectorToArray } from '../vector/vectorToArray'; export type MutableField = Field>; @@ -380,23 +281,3 @@ export class MutableDataFrame implements DataFrame, MutableVector { return toDataFrameDTO(this); } } - -interface CircularOptions { - append?: 'head' | 'tail'; - capacity?: number; -} - -/** - * This dataframe can have values constantly added, and will never - * exceed the given capacity - */ -export class CircularDataFrame extends MutableDataFrame { - constructor(options: CircularOptions) { - super(undefined, (buffer?: any[]) => { - return new CircularVector({ - ...options, - buffer, - }); - }); - } -} diff --git a/packages/grafana-data/src/dataframe/index.ts b/packages/grafana-data/src/dataframe/index.ts new file mode 100644 index 000000000000..e2cfc615ce43 --- /dev/null +++ b/packages/grafana-data/src/dataframe/index.ts @@ -0,0 +1,5 @@ +export * from './DataFrameView'; +export * from './FieldCache'; +export * from './CircularDataFrame'; +export * from './MutableDataFrame'; +export * from './processDataFrame'; diff --git a/packages/grafana-data/src/utils/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts similarity index 98% rename from packages/grafana-data/src/utils/processDataFrame.test.ts rename to packages/grafana-data/src/dataframe/processDataFrame.test.ts index a71dff6e9ca0..adf10690ec5a 100644 --- a/packages/grafana-data/src/utils/processDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -8,8 +8,8 @@ import { sortDataFrame, } from './processDataFrame'; import { FieldType, TimeSeries, TableData, DataFrameDTO } from '../types/index'; -import { dateTime } from './moment_wrapper'; -import { MutableDataFrame } from './dataFrameHelper'; +import { dateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; describe('toDataFrame', () => { it('converts timeseries to series', () => { diff --git a/packages/grafana-data/src/utils/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts similarity index 97% rename from packages/grafana-data/src/utils/processDataFrame.ts rename to packages/grafana-data/src/dataframe/processDataFrame.ts index e88bb54ddecc..6221bb4e658b 100644 --- a/packages/grafana-data/src/utils/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -17,10 +17,11 @@ import { FieldDTO, DataFrameDTO, } from '../types/index'; -import { isDateTime } from './moment_wrapper'; -import { ArrayVector, SortedVector } from './vector'; -import { MutableDataFrame } from './dataFrameHelper'; -import { deprecationWarning } from './deprecationWarning'; +import { isDateTime } from '../datetime/moment_wrapper'; +import { deprecationWarning } from '../utils/deprecationWarning'; +import { ArrayVector } from '../vector/ArrayVector'; +import { MutableDataFrame } from './MutableDataFrame'; +import { SortedVector } from '../vector/SortedVector'; function convertTableToDataFrame(table: TableData): DataFrame { const fields = table.columns.map(c => { diff --git a/packages/grafana-data/src/utils/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts similarity index 98% rename from packages/grafana-data/src/utils/datemath.test.ts rename to packages/grafana-data/src/datetime/datemath.test.ts index 6ffd9194d7f3..3443eea2d172 100644 --- a/packages/grafana-data/src/utils/datemath.test.ts +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -2,7 +2,7 @@ import sinon, { SinonFakeTimers } from 'sinon'; import each from 'lodash/each'; import * as dateMath from './datemath'; -import { dateTime, DurationUnit, DateTime } from '../utils/moment_wrapper'; +import { dateTime, DurationUnit, DateTime } from './moment_wrapper'; describe('DateMath', () => { const spans: DurationUnit[] = ['s', 'm', 'h', 'd', 'w', 'M', 'y']; diff --git a/packages/grafana-data/src/utils/datemath.ts b/packages/grafana-data/src/datetime/datemath.ts similarity index 99% rename from packages/grafana-data/src/utils/datemath.ts rename to packages/grafana-data/src/datetime/datemath.ts index 47fe47282c3e..29747e15bc72 100644 --- a/packages/grafana-data/src/utils/datemath.ts +++ b/packages/grafana-data/src/datetime/datemath.ts @@ -1,7 +1,7 @@ import includes from 'lodash/includes'; import isDate from 'lodash/isDate'; import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper'; -import { TimeZone } from '../types'; +import { TimeZone } from '../types/index'; const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's']; diff --git a/packages/grafana-data/src/datetime/index.ts b/packages/grafana-data/src/datetime/index.ts new file mode 100644 index 000000000000..bbaa4ebd95cb --- /dev/null +++ b/packages/grafana-data/src/datetime/index.ts @@ -0,0 +1,5 @@ +// Names are too general to export globally +import * as dateMath from './datemath'; +import * as rangeUtil from './rangeutil'; +export * from './moment_wrapper'; +export { dateMath, rangeUtil }; diff --git a/packages/grafana-data/src/utils/moment_wrapper.ts b/packages/grafana-data/src/datetime/moment_wrapper.ts similarity index 100% rename from packages/grafana-data/src/utils/moment_wrapper.ts rename to packages/grafana-data/src/datetime/moment_wrapper.ts diff --git a/packages/grafana-data/src/utils/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts similarity index 98% rename from packages/grafana-data/src/utils/rangeutil.ts rename to packages/grafana-data/src/datetime/rangeutil.ts index d8eb04ffd07d..fcf55b37f4df 100644 --- a/packages/grafana-data/src/utils/rangeutil.ts +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -4,7 +4,7 @@ import groupBy from 'lodash/groupBy'; import { RawTimeRange } from '../types/time'; import * as dateMath from './datemath'; -import { isDateTime, DateTime } from '../utils/moment_wrapper'; +import { isDateTime, DateTime } from './moment_wrapper'; const spans: { [key: string]: { display: string; section?: number } } = { s: { display: 'second' }, diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 53f3ef710111..c5f3add1d5b5 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -1,2 +1,7 @@ -export * from './utils/index'; -export * from './types/index'; +export * from './utils'; +export * from './types'; +export * from './vector'; +export * from './dataframe'; +export * from './transformations'; +export * from './datetime'; +export * from './text'; diff --git a/packages/grafana-data/src/text/index.ts b/packages/grafana-data/src/text/index.ts new file mode 100644 index 000000000000..b59a1d992d5c --- /dev/null +++ b/packages/grafana-data/src/text/index.ts @@ -0,0 +1,3 @@ +export * from './string'; +export * from './markdown'; +export * from './text'; diff --git a/packages/grafana-data/src/utils/markdown.test.ts b/packages/grafana-data/src/text/markdown.test.ts similarity index 100% rename from packages/grafana-data/src/utils/markdown.test.ts rename to packages/grafana-data/src/text/markdown.test.ts diff --git a/packages/grafana-data/src/utils/markdown.ts b/packages/grafana-data/src/text/markdown.ts similarity index 100% rename from packages/grafana-data/src/utils/markdown.ts rename to packages/grafana-data/src/text/markdown.ts diff --git a/packages/grafana-data/src/utils/string.test.ts b/packages/grafana-data/src/text/string.test.ts similarity index 100% rename from packages/grafana-data/src/utils/string.test.ts rename to packages/grafana-data/src/text/string.test.ts diff --git a/packages/grafana-data/src/utils/string.ts b/packages/grafana-data/src/text/string.ts similarity index 100% rename from packages/grafana-data/src/utils/string.ts rename to packages/grafana-data/src/text/string.ts diff --git a/packages/grafana-data/src/utils/text.test.ts b/packages/grafana-data/src/text/text.test.ts similarity index 100% rename from packages/grafana-data/src/utils/text.test.ts rename to packages/grafana-data/src/text/text.test.ts diff --git a/packages/grafana-data/src/utils/text.ts b/packages/grafana-data/src/text/text.ts similarity index 100% rename from packages/grafana-data/src/utils/text.ts rename to packages/grafana-data/src/text/text.ts diff --git a/packages/grafana-data/src/utils/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts similarity index 96% rename from packages/grafana-data/src/utils/fieldReducer.test.ts rename to packages/grafana-data/src/transformations/fieldReducer.test.ts index c4bb1f1e4149..0dfc1d349a1e 100644 --- a/packages/grafana-data/src/utils/fieldReducer.test.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -3,9 +3,9 @@ import difference from 'lodash/difference'; import { fieldReducers, ReducerID, reduceField } from './fieldReducer'; import { Field, FieldType } from '../types/index'; -import { MutableDataFrame } from './dataFrameHelper'; -import { ArrayVector } from './vector'; -import { guessFieldTypeFromValue } from './processDataFrame'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { ArrayVector } from '../vector/ArrayVector'; /** * Run a reducer and get back the value diff --git a/packages/grafana-data/src/utils/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts similarity index 98% rename from packages/grafana-data/src/utils/fieldReducer.ts rename to packages/grafana-data/src/transformations/fieldReducer.ts index 7655a735e3df..83714374e482 100644 --- a/packages/grafana-data/src/utils/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -1,8 +1,8 @@ // Libraries import isNumber from 'lodash/isNumber'; -import { NullValueMode, Field } from '../types'; -import { Registry, RegistryItem } from './registry'; +import { NullValueMode, Field } from '../types/index'; +import { Registry, RegistryItem } from '../utils/Registry'; export enum ReducerID { sum = 'sum', diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts new file mode 100644 index 000000000000..798dabce10b1 --- /dev/null +++ b/packages/grafana-data/src/transformations/index.ts @@ -0,0 +1,7 @@ +export * from './matchers/ids'; +export * from './transformers/ids'; +export * from './matchers'; +export * from './transformers'; +export * from './fieldReducer'; +export { FilterFieldsByNameTransformerOptions } from './transformers/filterByName'; +export { ReduceTransformerOptions } from './transformers/reduce'; diff --git a/packages/grafana-data/src/utils/matchers/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts similarity index 55% rename from packages/grafana-data/src/utils/matchers/matchers.ts rename to packages/grafana-data/src/transformations/matchers.ts index d5b45ff771a1..62f127f7518b 100644 --- a/packages/grafana-data/src/utils/matchers/matchers.ts +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -1,27 +1,16 @@ -import { Field, DataFrame } from '../../types/dataFrame'; -import { Registry, RegistryItemWithOptions } from '../registry'; - -export type FieldMatcher = (field: Field) => boolean; -export type FrameMatcher = (frame: DataFrame) => boolean; - -export interface FieldMatcherInfo extends RegistryItemWithOptions { - get: (options: TOptions) => FieldMatcher; -} - -export interface FrameMatcherInfo extends RegistryItemWithOptions { - get: (options: TOptions) => FrameMatcher; -} - -export interface MatcherConfig { - id: string; - options?: TOptions; -} - // Load the Buildtin matchers -import { getFieldPredicateMatchers, getFramePredicateMatchers } from './predicates'; -import { getFieldNameMatchers, getFrameNameMatchers } from './nameMatcher'; -import { getFieldTypeMatchers } from './fieldTypeMatcher'; -import { getRefIdMatchers } from './refIdMatcher'; +import { getFieldPredicateMatchers, getFramePredicateMatchers } from './matchers/predicates'; +import { getFieldNameMatchers, getFrameNameMatchers } from './matchers/nameMatcher'; +import { getFieldTypeMatchers } from './matchers/fieldTypeMatcher'; +import { getRefIdMatchers } from './matchers/refIdMatcher'; +import { + FieldMatcherInfo, + MatcherConfig, + FrameMatcherInfo, + FieldMatcher, + FrameMatcher, +} from '../types/transformations'; +import { Registry } from '../utils/Registry'; export const fieldMatchers = new Registry(() => { return [ diff --git a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts similarity index 84% rename from packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts rename to packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts index 99884f3d60c1..cf8f7006e339 100644 --- a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts @@ -1,7 +1,7 @@ import { FieldType } from '../../types/dataFrame'; -import { fieldMatchers } from './matchers'; +import { fieldMatchers } from '../matchers'; import { FieldMatcherID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; export const simpleSeriesWithTypes = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts similarity index 95% rename from packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts rename to packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts index 385bcefca44f..38dc9c5ef0ac 100644 --- a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts @@ -1,6 +1,6 @@ import { Field, FieldType } from '../../types/dataFrame'; -import { FieldMatcherInfo } from './matchers'; import { FieldMatcherID } from './ids'; +import { FieldMatcherInfo } from '../../types/transformations'; // General Field matcher const fieldTypeMacher: FieldMatcherInfo = { diff --git a/packages/grafana-data/src/utils/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts similarity index 100% rename from packages/grafana-data/src/utils/matchers/ids.ts rename to packages/grafana-data/src/transformations/matchers/ids.ts diff --git a/packages/grafana-data/src/utils/matchers/matchers.test.ts b/packages/grafana-data/src/transformations/matchers/matchers.test.ts similarity index 85% rename from packages/grafana-data/src/utils/matchers/matchers.test.ts rename to packages/grafana-data/src/transformations/matchers/matchers.test.ts index 0faeabb14aeb..81ea76b2b757 100644 --- a/packages/grafana-data/src/utils/matchers/matchers.test.ts +++ b/packages/grafana-data/src/transformations/matchers/matchers.test.ts @@ -1,4 +1,4 @@ -import { fieldMatchers } from './matchers'; +import { fieldMatchers } from '../matchers'; import { FieldMatcherID } from './ids'; describe('Matchers', () => { diff --git a/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts similarity index 92% rename from packages/grafana-data/src/utils/matchers/nameMatcher.test.ts rename to packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts index 7f2880ff0f63..0f3df71088f8 100644 --- a/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -1,6 +1,6 @@ -import { getFieldMatcher } from './matchers'; +import { getFieldMatcher } from '../matchers'; import { FieldMatcherID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; describe('Field Name Matcher', () => { it('Match all with wildcard regex', () => { diff --git a/packages/grafana-data/src/utils/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts similarity index 90% rename from packages/grafana-data/src/utils/matchers/nameMatcher.ts rename to packages/grafana-data/src/transformations/matchers/nameMatcher.ts index 626b6a908a8a..1061c2d80477 100644 --- a/packages/grafana-data/src/utils/matchers/nameMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -1,7 +1,7 @@ import { Field, DataFrame } from '../../types/dataFrame'; -import { FieldMatcherInfo, FrameMatcherInfo } from './matchers'; import { FieldMatcherID, FrameMatcherID } from './ids'; -import { stringToJsRegex } from '../string'; +import { FieldMatcherInfo, FrameMatcherInfo } from '../../types/transformations'; +import { stringToJsRegex } from '../../text/string'; // General Field matcher const fieldNameMacher: FieldMatcherInfo = { diff --git a/packages/grafana-data/src/utils/matchers/predicates.test.ts b/packages/grafana-data/src/transformations/matchers/predicates.test.ts similarity index 92% rename from packages/grafana-data/src/utils/matchers/predicates.test.ts rename to packages/grafana-data/src/transformations/matchers/predicates.test.ts index 97e95ba3129b..cb4fd1909c14 100644 --- a/packages/grafana-data/src/utils/matchers/predicates.test.ts +++ b/packages/grafana-data/src/transformations/matchers/predicates.test.ts @@ -1,7 +1,8 @@ import { FieldType } from '../../types/dataFrame'; -import { MatcherConfig, fieldMatchers } from './matchers'; +import { fieldMatchers } from '../matchers'; import { simpleSeriesWithTypes } from './fieldTypeMatcher.test'; import { FieldMatcherID, MatcherID } from './ids'; +import { MatcherConfig } from '../../types/transformations'; const matchesNumberConfig: MatcherConfig = { id: FieldMatcherID.byType, diff --git a/packages/grafana-data/src/utils/matchers/predicates.ts b/packages/grafana-data/src/transformations/matchers/predicates.ts similarity index 97% rename from packages/grafana-data/src/utils/matchers/predicates.ts rename to packages/grafana-data/src/transformations/matchers/predicates.ts index 502cceef3111..e122b84ab3c3 100644 --- a/packages/grafana-data/src/utils/matchers/predicates.ts +++ b/packages/grafana-data/src/transformations/matchers/predicates.ts @@ -1,14 +1,7 @@ import { Field, DataFrame } from '../../types/dataFrame'; import { MatcherID } from './ids'; -import { - FrameMatcherInfo, - FieldMatcherInfo, - MatcherConfig, - getFieldMatcher, - fieldMatchers, - getFrameMatchers, - frameMatchers, -} from './matchers'; +import { getFieldMatcher, fieldMatchers, getFrameMatchers, frameMatchers } from '../matchers'; +import { FieldMatcherInfo, MatcherConfig, FrameMatcherInfo } from '../../types/transformations'; const anyFieldMatcher: FieldMatcherInfo = { id: MatcherID.anyMatch, diff --git a/packages/grafana-data/src/utils/matchers/refIdMatcher.ts b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts similarity index 89% rename from packages/grafana-data/src/utils/matchers/refIdMatcher.ts rename to packages/grafana-data/src/transformations/matchers/refIdMatcher.ts index 51b0db3af805..0d525d53dd91 100644 --- a/packages/grafana-data/src/utils/matchers/refIdMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts @@ -1,6 +1,6 @@ import { DataFrame } from '../../types/dataFrame'; -import { FrameMatcherInfo } from './matchers'; import { FrameMatcherID } from './ids'; +import { FrameMatcherInfo } from '../../types/transformations'; // General Field matcher const refIdMacher: FrameMatcherInfo = { diff --git a/packages/grafana-data/src/utils/transformers/transformers.test.ts b/packages/grafana-data/src/transformations/transformers.test.ts similarity index 62% rename from packages/grafana-data/src/utils/transformers/transformers.test.ts rename to packages/grafana-data/src/transformations/transformers.test.ts index 81df7478d594..655587e26679 100644 --- a/packages/grafana-data/src/utils/transformers/transformers.test.ts +++ b/packages/grafana-data/src/transformations/transformers.test.ts @@ -1,13 +1,13 @@ -import { DataTransformerID } from './ids'; -import { dataTransformers } from './transformers'; -import { toDataFrame } from '../processDataFrame'; -import { ReducerID } from '../fieldReducer'; -import { DataFrameView } from '../dataFrameView'; +import { DataTransformerID } from './transformers/ids'; +import { transformersRegistry } from './transformers'; +import { toDataFrame } from '../dataframe/processDataFrame'; +import { ReducerID } from './fieldReducer'; +import { DataFrameView } from '../dataframe/DataFrameView'; describe('Transformers', () => { it('should load all transformeres', () => { for (const name of Object.keys(DataTransformerID)) { - const calc = dataTransformers.get(name); + const calc = transformersRegistry.get(name); expect(calc.id).toBe(name); } }); @@ -20,7 +20,7 @@ describe('Transformers', () => { }); it('should use fluent API', () => { - const results = dataTransformers.reduce([seriesWithValues], { + const results = transformersRegistry.reduce([seriesWithValues], { reducers: [ReducerID.first], }); expect(results.length).toBe(1); diff --git a/packages/grafana-data/src/utils/transformers/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts similarity index 71% rename from packages/grafana-data/src/utils/transformers/transformers.ts rename to packages/grafana-data/src/transformations/transformers.ts index 04cee6e76341..189a76d03abe 100644 --- a/packages/grafana-data/src/utils/transformers/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -1,19 +1,13 @@ -import { DataFrame } from '../../types/dataFrame'; -import { Registry, RegistryItemWithOptions } from '../registry'; - -/** - * Immutable data transformation - */ -export type DataTransformer = (data: DataFrame[]) => DataFrame[]; - -export interface DataTransformerInfo extends RegistryItemWithOptions { - transformer: (options: TOptions) => DataTransformer; -} +import { DataFrame } from '../types/dataFrame'; +import { Registry } from '../utils/Registry'; +// Initalize the Registry -export interface DataTransformerConfig { - id: string; - options: TOptions; -} +import { appendTransformer, AppendOptions } from './transformers/append'; +import { reduceTransformer, ReduceTransformerOptions } from './transformers/reduce'; +import { filterFieldsTransformer, filterFramesTransformer } from './transformers/filter'; +import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './transformers/filterByName'; +import { noopTransformer } from './transformers/noop'; +import { DataTransformerInfo, DataTransformerConfig } from '../types/transformations'; /** * Apply configured transformations to the input data @@ -21,7 +15,7 @@ export interface DataTransformerConfig { export function transformDataFrame(options: DataTransformerConfig[], data: DataFrame[]): DataFrame[] { let processed = data; for (const config of options) { - const info = dataTransformers.get(config.id); + const info = transformersRegistry.get(config.id); const transformer = info.transformer(config.options); const after = transformer(processed); @@ -43,14 +37,6 @@ export function transformDataFrame(options: DataTransformerConfig[], data: DataF return processed; } -// Initalize the Registry - -import { appendTransformer, AppendOptions } from './append'; -import { reduceTransformer, ReduceTransformerOptions } from './reduce'; -import { filterFieldsTransformer, filterFramesTransformer } from './filter'; -import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './filterByName'; -import { noopTransformer } from './noop'; - /** * Registry of transformation options that can be driven by * stored configuration files. @@ -73,7 +59,7 @@ class TransformerRegistry extends Registry { } } -export const dataTransformers = new TransformerRegistry(() => [ +export const transformersRegistry = new TransformerRegistry(() => [ noopTransformer, filterFieldsTransformer, filterFieldsByNameTransformer, diff --git a/packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap b/packages/grafana-data/src/transformations/transformers/__snapshots__/reduce.test.ts.snap similarity index 100% rename from packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap rename to packages/grafana-data/src/transformations/transformers/__snapshots__/reduce.test.ts.snap diff --git a/packages/grafana-data/src/utils/transformers/append.test.ts b/packages/grafana-data/src/transformations/transformers/append.test.ts similarity index 79% rename from packages/grafana-data/src/utils/transformers/append.test.ts rename to packages/grafana-data/src/transformations/transformers/append.test.ts index 4881c714f28e..6d92627dccdd 100644 --- a/packages/grafana-data/src/utils/transformers/append.test.ts +++ b/packages/grafana-data/src/transformations/transformers/append.test.ts @@ -1,6 +1,7 @@ -import { transformDataFrame, dataTransformers } from './transformers'; import { DataTransformerID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { transformDataFrame } from '../transformers'; +import { transformersRegistry } from '../transformers'; const seriesAB = toDataFrame({ columns: [{ text: 'A' }, { text: 'B' }], @@ -24,7 +25,7 @@ describe('Append Transformer', () => { id: DataTransformerID.append, options: {}, }; - const x = dataTransformers.get(DataTransformerID.append); + const x = transformersRegistry.get(DataTransformerID.append); expect(x.id).toBe(cfg.id); const processed = transformDataFrame([cfg], [seriesAB, seriesBC])[0]; diff --git a/packages/grafana-data/src/utils/transformers/append.ts b/packages/grafana-data/src/transformations/transformers/append.ts similarity index 91% rename from packages/grafana-data/src/utils/transformers/append.ts rename to packages/grafana-data/src/transformations/transformers/append.ts index b982cdb14783..952f006bbe2b 100644 --- a/packages/grafana-data/src/utils/transformers/append.ts +++ b/packages/grafana-data/src/transformations/transformers/append.ts @@ -1,7 +1,7 @@ -import { DataTransformerInfo } from './transformers'; import { DataFrame } from '../../types/dataFrame'; import { DataTransformerID } from './ids'; -import { MutableDataFrame } from '../dataFrameHelper'; +import { MutableDataFrame } from '../../dataframe/MutableDataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; export interface AppendOptions {} diff --git a/packages/grafana-data/src/utils/transformers/filter.test.ts b/packages/grafana-data/src/transformations/transformers/filter.test.ts similarity index 87% rename from packages/grafana-data/src/utils/transformers/filter.test.ts rename to packages/grafana-data/src/transformations/transformers/filter.test.ts index 28b3c11f6f70..af8846674bc2 100644 --- a/packages/grafana-data/src/utils/transformers/filter.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filter.test.ts @@ -1,8 +1,8 @@ import { FieldType } from '../../types/dataFrame'; -import { FieldMatcherID } from '../matchers/ids'; -import { transformDataFrame } from './transformers'; import { DataTransformerID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldMatcherID } from '../matchers/ids'; +import { transformDataFrame } from '../transformers'; export const simpleSeriesWithTypes = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/transformers/filter.ts b/packages/grafana-data/src/transformations/transformers/filter.ts similarity index 95% rename from packages/grafana-data/src/utils/transformers/filter.ts rename to packages/grafana-data/src/transformations/transformers/filter.ts index 7837c03d233f..fcce7766c872 100644 --- a/packages/grafana-data/src/utils/transformers/filter.ts +++ b/packages/grafana-data/src/transformations/transformers/filter.ts @@ -1,9 +1,9 @@ -import { DataTransformerInfo } from './transformers'; import { noopTransformer } from './noop'; import { DataFrame, Field } from '../../types/dataFrame'; -import { FieldMatcherID } from '../matchers/ids'; import { DataTransformerID } from './ids'; -import { MatcherConfig, getFieldMatcher, getFrameMatchers } from '../matchers/matchers'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { FieldMatcherID } from '../matchers/ids'; +import { getFieldMatcher, getFrameMatchers } from '../matchers'; export interface FilterOptions { include?: MatcherConfig; diff --git a/packages/grafana-data/src/utils/transformers/filterByName.test.ts b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts similarity index 91% rename from packages/grafana-data/src/utils/transformers/filterByName.test.ts rename to packages/grafana-data/src/transformations/transformers/filterByName.test.ts index 755250654128..ac764da977de 100644 --- a/packages/grafana-data/src/utils/transformers/filterByName.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts @@ -1,6 +1,7 @@ -import { toDataFrame, transformDataFrame } from '../index'; -import { FieldType } from '../../index'; import { DataTransformerID } from './ids'; +import { transformDataFrame } from '../transformers'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; export const seriesWithNamesToMatch = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/transformers/filterByName.ts b/packages/grafana-data/src/transformations/transformers/filterByName.ts similarity index 94% rename from packages/grafana-data/src/utils/transformers/filterByName.ts rename to packages/grafana-data/src/transformations/transformers/filterByName.ts index 1d72116dc72b..7ece827065c4 100644 --- a/packages/grafana-data/src/utils/transformers/filterByName.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByName.ts @@ -1,7 +1,7 @@ -import { DataTransformerInfo } from './transformers'; -import { FieldMatcherID } from '../matchers/ids'; import { DataTransformerID } from './ids'; import { filterFieldsTransformer, FilterOptions } from './filter'; +import { DataTransformerInfo } from '../../types/transformations'; +import { FieldMatcherID } from '../matchers/ids'; export interface FilterFieldsByNameTransformerOptions { include?: string; diff --git a/packages/grafana-data/src/utils/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts similarity index 100% rename from packages/grafana-data/src/utils/transformers/ids.ts rename to packages/grafana-data/src/transformations/transformers/ids.ts diff --git a/packages/grafana-data/src/utils/transformers/noop.ts b/packages/grafana-data/src/transformations/transformers/noop.ts similarity index 90% rename from packages/grafana-data/src/utils/transformers/noop.ts rename to packages/grafana-data/src/transformations/transformers/noop.ts index 96bded6b89a3..7feb0b853d6f 100644 --- a/packages/grafana-data/src/utils/transformers/noop.ts +++ b/packages/grafana-data/src/transformations/transformers/noop.ts @@ -1,6 +1,6 @@ -import { DataTransformerInfo } from './transformers'; import { DataTransformerID } from './ids'; import { DataFrame } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; export interface NoopTransformerOptions { include?: string; diff --git a/packages/grafana-data/src/utils/transformers/reduce.test.ts b/packages/grafana-data/src/transformations/transformers/reduce.test.ts similarity index 83% rename from packages/grafana-data/src/utils/transformers/reduce.test.ts rename to packages/grafana-data/src/transformations/transformers/reduce.test.ts index ed0ce16b6f71..244f87dff8c7 100644 --- a/packages/grafana-data/src/utils/transformers/reduce.test.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.test.ts @@ -1,7 +1,7 @@ -import { transformDataFrame } from './transformers'; import { ReducerID } from '../fieldReducer'; import { DataTransformerID } from './ids'; -import { toDataFrame, toDataFrameDTO } from '../processDataFrame'; +import { toDataFrame, toDataFrameDTO } from '../../dataframe/processDataFrame'; +import { transformDataFrame } from '../transformers'; const seriesWithValues = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/transformers/reduce.ts b/packages/grafana-data/src/transformations/transformers/reduce.ts similarity index 89% rename from packages/grafana-data/src/utils/transformers/reduce.ts rename to packages/grafana-data/src/transformations/transformers/reduce.ts index a30a9c0ef123..ca61bc93c2ea 100644 --- a/packages/grafana-data/src/utils/transformers/reduce.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.ts @@ -1,12 +1,12 @@ -import { DataTransformerInfo } from './transformers'; -import { DataFrame, FieldType, Field } from '../../types/dataFrame'; -import { MatcherConfig, getFieldMatcher } from '../matchers/matchers'; -import { alwaysFieldMatcher } from '../matchers/predicates'; import { DataTransformerID } from './ids'; +import { MatcherConfig, DataTransformerInfo } from '../../types/transformations'; import { ReducerID, fieldReducers, reduceField } from '../fieldReducer'; +import { alwaysFieldMatcher } from '../matchers/predicates'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector/ArrayVector'; import { KeyValue } from '../../types/data'; -import { ArrayVector } from '../vector'; -import { guessFieldTypeForField } from '../processDataFrame'; +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { getFieldMatcher } from '../matchers'; export interface ReduceTransformerOptions { reducers: ReducerID[]; diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index add607d0a53b..03d965160ae8 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -1,9 +1,10 @@ import { Threshold } from './threshold'; import { ValueMapping } from './valueMapping'; import { QueryResultBase, Labels, NullValueMode } from './data'; -import { FieldCalcs } from '../utils/index'; import { DisplayProcessor } from './displayValue'; import { DataLink } from './dataLink'; +import { Vector } from './vector'; +import { FieldCalcs } from '../transformations/fieldReducer'; export enum FieldType { time = 'time', // or date @@ -44,25 +45,6 @@ export interface FieldConfig { noValue?: string; } -export interface Vector { - length: number; - - /** - * Access the value by index (Like an array) - */ - get(index: number): T; - - /** - * Get the resutls as an array. - */ - toArray(): T[]; - - /** - * Return the values as a simple array for json serialization - */ - toJSON(): any; // same results as toArray() -} - export interface Field> { name: string; // The column name type: FieldType; diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 08340b2d0104..fd8d362074ab 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -11,3 +11,4 @@ export * from './valueMapping'; export * from './displayValue'; export * from './graph'; export * from './ScopedVars'; +export * from './transformations'; diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts index ae8137277f38..0eb2c51929d1 100644 --- a/packages/grafana-data/src/types/time.ts +++ b/packages/grafana-data/src/types/time.ts @@ -1,4 +1,4 @@ -import { DateTime } from '../utils/moment_wrapper'; +import { DateTime } from '../datetime/moment_wrapper'; export interface RawTimeRange { from: DateTime | string; diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts new file mode 100644 index 000000000000..6fab55410f53 --- /dev/null +++ b/packages/grafana-data/src/types/transformations.ts @@ -0,0 +1,32 @@ +import { DataFrame, Field } from './dataFrame'; +import { RegistryItemWithOptions } from '../utils/Registry'; + +/** + * Immutable data transformation + */ +export type DataTransformer = (data: DataFrame[]) => DataFrame[]; + +export interface DataTransformerInfo extends RegistryItemWithOptions { + transformer: (options: TOptions) => DataTransformer; +} + +export interface DataTransformerConfig { + id: string; + options: TOptions; +} + +export type FieldMatcher = (field: Field) => boolean; +export type FrameMatcher = (frame: DataFrame) => boolean; + +export interface FieldMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FieldMatcher; +} + +export interface FrameMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FrameMatcher; +} + +export interface MatcherConfig { + id: string; + options?: TOptions; +} diff --git a/packages/grafana-data/src/types/vector.ts b/packages/grafana-data/src/types/vector.ts new file mode 100644 index 000000000000..7e667d725a9f --- /dev/null +++ b/packages/grafana-data/src/types/vector.ts @@ -0,0 +1,40 @@ +export interface Vector { + length: number; + + /** + * Access the value by index (Like an array) + */ + get(index: number): T; + + /** + * Get the resutls as an array. + */ + toArray(): T[]; + + /** + * Return the values as a simple array for json serialization + */ + toJSON(): any; // same results as toArray() +} + +/** + * Apache arrow vectors are Read/Write + */ +export interface ReadWriteVector extends Vector { + set: (index: number, value: T) => void; +} + +/** + * Vector with standard manipulation functions + */ +export interface MutableVector extends ReadWriteVector { + /** + * Adds the value to the vector + */ + add: (value: T) => void; + + /** + * modifies the vector so it is now the oposite order + */ + reverse: () => void; +} diff --git a/packages/grafana-data/src/utils/registry.ts b/packages/grafana-data/src/utils/Registry.ts similarity index 100% rename from packages/grafana-data/src/utils/registry.ts rename to packages/grafana-data/src/utils/Registry.ts diff --git a/packages/grafana-data/src/utils/csv.test.ts b/packages/grafana-data/src/utils/csv.test.ts index 23adebcf467b..c7ed640bfa9f 100644 --- a/packages/grafana-data/src/utils/csv.test.ts +++ b/packages/grafana-data/src/utils/csv.test.ts @@ -1,9 +1,9 @@ import { readCSV, toCSV, CSVHeaderStyle } from './csv'; -import { getDataFrameRow } from './processDataFrame'; +import { getDataFrameRow } from '../dataframe/processDataFrame'; // Test with local CSV files import fs from 'fs'; -import { toDataFrameDTO } from './processDataFrame'; +import { toDataFrameDTO } from '../dataframe/processDataFrame'; describe('read csv', () => { it('should get X and y', () => { diff --git a/packages/grafana-data/src/utils/csv.ts b/packages/grafana-data/src/utils/csv.ts index fd3664676e22..3d7871631018 100644 --- a/packages/grafana-data/src/utils/csv.ts +++ b/packages/grafana-data/src/utils/csv.ts @@ -5,8 +5,8 @@ import isNumber from 'lodash/isNumber'; // Types import { DataFrame, Field, FieldType, FieldConfig } from '../types'; -import { guessFieldTypeFromValue } from './processDataFrame'; -import { MutableDataFrame } from './dataFrameHelper'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; export enum CSVHeaderStyle { full, diff --git a/packages/grafana-data/src/utils/fieldParser.ts b/packages/grafana-data/src/utils/fieldParser.ts new file mode 100644 index 000000000000..939f4e401f29 --- /dev/null +++ b/packages/grafana-data/src/utils/fieldParser.ts @@ -0,0 +1,28 @@ +import { Field, FieldType } from '../types/dataFrame'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; + +export function makeFieldParser(value: any, field: Field): (value: string) => any { + if (!field.type) { + if (field.name === 'time' || field.name === 'Time') { + field.type = FieldType.time; + } else { + field.type = guessFieldTypeFromValue(value); + } + } + + if (field.type === FieldType.number) { + return (value: string) => { + return parseFloat(value); + }; + } + + // Will convert anything that starts with "T" to true + if (field.type === FieldType.boolean) { + return (value: string) => { + return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); + }; + } + + // Just pass the string back + return (value: string) => value; +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 2479adafe589..5f3d7d23ef4b 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -1,29 +1,10 @@ -export * from './string'; -export * from './registry'; -export * from './markdown'; -export * from './processDataFrame'; +export * from './Registry'; export * from './deprecationWarning'; export * from './csv'; -export * from './fieldReducer'; export * from './logs'; export * from './labels'; export * from './labels'; export * from './object'; -export * from './moment_wrapper'; export * from './thresholds'; -export * from './text'; -export * from './dataFrameHelper'; -export * from './dataFrameView'; -export * from './vector'; export { getMappedValue } from './valueMappings'; - -// Names are too general to export globally -import * as dateMath from './datemath'; -import * as rangeUtil from './rangeutil'; -export { dateMath, rangeUtil }; - -export * from './matchers/ids'; -export * from './matchers/matchers'; -export * from './transformers/ids'; -export * from './transformers/transformers'; diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index 196a6a28dd73..b14542d57fea 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -2,7 +2,7 @@ import { countBy, chain, map, escapeRegExp } from 'lodash'; import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs'; import { DataFrame, FieldType } from '../types/index'; -import { ArrayVector } from './vector'; +import { ArrayVector } from '../vector/ArrayVector'; const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/; diff --git a/packages/grafana-data/src/utils/vector.ts b/packages/grafana-data/src/utils/vector.ts deleted file mode 100644 index 0a625b01b494..000000000000 --- a/packages/grafana-data/src/utils/vector.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Vector } from '../types/dataFrame'; - -export function vectorToArray(v: Vector): T[] { - const arr: T[] = []; - for (let i = 0; i < v.length; i++) { - arr[i] = v.get(i); - } - return arr; -} - -/** - * Apache arrow vectors are Read/Write - */ -export interface ReadWriteVector extends Vector { - set: (index: number, value: T) => void; -} - -/** - * Vector with standard manipulation functions - */ -export interface MutableVector extends ReadWriteVector { - /** - * Adds the value to the vector - */ - add: (value: T) => void; - - /** - * modifies the vector so it is now the oposite order - */ - reverse: () => void; -} - -export class ArrayVector implements MutableVector { - buffer: T[]; - - constructor(buffer?: T[]) { - this.buffer = buffer ? buffer : []; - } - - get length() { - return this.buffer.length; - } - - add(value: T) { - this.buffer.push(value); - } - - get(index: number): T { - return this.buffer[index]; - } - - set(index: number, value: T) { - this.buffer[index] = value; - } - - reverse() { - this.buffer.reverse(); - } - - toArray(): T[] { - return this.buffer; - } - - toJSON(): T[] { - return this.buffer; - } -} - -export class ConstantVector implements Vector { - constructor(private value: T, private len: number) {} - - get length() { - return this.len; - } - - get(index: number): T { - return this.value; - } - - toArray(): T[] { - const arr = new Array(this.length); - return arr.fill(this.value); - } - - toJSON(): T[] { - return this.toArray(); - } -} - -export class ScaledVector implements Vector { - constructor(private source: Vector, private scale: number) {} - - get length(): number { - return this.source.length; - } - - get(index: number): number { - return this.source.get(index) * this.scale; - } - - toArray(): number[] { - return vectorToArray(this); - } - - toJSON(): number[] { - return vectorToArray(this); - } -} - -/** - * Values are returned in the order defined by the input parameter - */ -export class SortedVector implements Vector { - constructor(private source: Vector, private order: number[]) {} - - get length(): number { - return this.source.length; - } - - get(index: number): T { - return this.source.get(this.order[index]); - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} - -interface CircularOptions { - buffer?: T[]; - append?: 'head' | 'tail'; - capacity?: number; -} - -/** - * Circular vector uses a single buffer to capture a stream of values - * overwriting the oldest value on add. - * - * This supports addting to the 'head' or 'tail' and will grow the buffer - * to match a configured capacity. - */ -export class CircularVector implements MutableVector { - private buffer: T[]; - private index: number; - private capacity: number; - private tail: boolean; - - constructor(options: CircularOptions) { - this.buffer = options.buffer || []; - this.capacity = this.buffer.length; - this.tail = 'head' !== options.append; - this.index = 0; - - this.add = this.getAddFunction(); - if (options.capacity) { - this.setCapacity(options.capacity); - } - } - - /** - * This gets the appropriate add function depending on the buffer state: - * * head vs tail - * * growing buffer vs overwriting values - */ - private getAddFunction() { - // When we are not at capacity, it should actually modify the buffer - if (this.capacity > this.buffer.length) { - if (this.tail) { - return (value: T) => { - this.buffer.push(value); - if (this.buffer.length >= this.capacity) { - this.add = this.getAddFunction(); - } - }; - } else { - return (value: T) => { - this.buffer.unshift(value); - if (this.buffer.length >= this.capacity) { - this.add = this.getAddFunction(); - } - }; - } - } - - if (this.tail) { - return (value: T) => { - this.buffer[this.index] = value; - this.index = (this.index + 1) % this.buffer.length; - }; - } - - // Append values to the head - return (value: T) => { - let idx = this.index - 1; - if (idx < 0) { - idx = this.buffer.length - 1; - } - this.buffer[idx] = value; - this.index = idx; - }; - } - - setCapacity(v: number) { - if (this.capacity === v) { - return; - } - // Make a copy so it is in order and new additions can be at the head or tail - const copy = this.toArray(); - if (v > this.length) { - this.buffer = copy; - } else if (v < this.capacity) { - // Shrink the buffer - const delta = this.length - v; - if (this.tail) { - this.buffer = copy.slice(delta, copy.length); // Keep last items - } else { - this.buffer = copy.slice(0, copy.length - delta); // Keep first items - } - } - this.capacity = v; - this.index = 0; - this.add = this.getAddFunction(); - } - - setAppendMode(mode: 'head' | 'tail') { - const tail = 'head' !== mode; - if (tail !== this.tail) { - this.buffer = this.toArray().reverse(); - this.index = 0; - this.tail = tail; - this.add = this.getAddFunction(); - } - } - - reverse() { - this.buffer.reverse(); - } - - /** - * Add the value to the buffer - */ - add: (value: T) => void; - - get(index: number) { - return this.buffer[(index + this.index) % this.buffer.length]; - } - - set(index: number, value: T) { - this.buffer[(index + this.index) % this.buffer.length] = value; - } - - get length() { - return this.buffer.length; - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} - -interface AppendedVectorInfo { - start: number; - end: number; - values: Vector; -} - -/** - * This may be more trouble than it is worth. This trades some computation time for - * RAM -- rather than allocate a new array the size of all previous arrays, this just - * points the correct index to their original array values - */ -export class AppendedVectors implements Vector { - length = 0; - source: Array> = new Array>(); - - constructor(startAt = 0) { - this.length = startAt; - } - - /** - * Make the vector look like it is this long - */ - setLength(length: number) { - if (length > this.length) { - // make the vector longer (filling with undefined) - this.length = length; - } else if (length < this.length) { - // make the array shorter - const sources: Array> = new Array>(); - for (const src of this.source) { - sources.push(src); - if (src.end > length) { - src.end = length; - break; - } - } - this.source = sources; - this.length = length; - } - } - - append(v: Vector): AppendedVectorInfo { - const info = { - start: this.length, - end: this.length + v.length, - values: v, - }; - this.length = info.end; - this.source.push(info); - return info; - } - - get(index: number): T { - for (let i = 0; i < this.source.length; i++) { - const src = this.source[i]; - if (index >= src.start && index < src.end) { - return src.values.get(index - src.start); - } - } - return (undefined as unknown) as T; - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} diff --git a/packages/grafana-data/src/vector/AppendedVectors.test.ts b/packages/grafana-data/src/vector/AppendedVectors.test.ts new file mode 100644 index 000000000000..9d39b47ce091 --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.test.ts @@ -0,0 +1,23 @@ +import { ArrayVector } from './ArrayVector'; +import { AppendedVectors } from './AppendedVectors'; + +describe('Check Appending Vector', () => { + it('should transparently join them', () => { + const appended = new AppendedVectors(); + appended.append(new ArrayVector([1, 2, 3])); + appended.append(new ArrayVector([4, 5, 6])); + appended.append(new ArrayVector([7, 8, 9])); + expect(appended.length).toEqual(9); + + appended.setLength(5); + expect(appended.length).toEqual(5); + appended.append(new ArrayVector(['a', 'b', 'c'])); + expect(appended.length).toEqual(8); + expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); + + appended.setLength(2); + appended.setLength(6); + appended.append(new ArrayVector(['x', 'y', 'z'])); + expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); + }); +}); diff --git a/packages/grafana-data/src/vector/AppendedVectors.ts b/packages/grafana-data/src/vector/AppendedVectors.ts new file mode 100644 index 000000000000..7cfc0d42c470 --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.ts @@ -0,0 +1,73 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +interface AppendedVectorInfo { + start: number; + end: number; + values: Vector; +} + +/** + * This may be more trouble than it is worth. This trades some computation time for + * RAM -- rather than allocate a new array the size of all previous arrays, this just + * points the correct index to their original array values + */ +export class AppendedVectors implements Vector { + length = 0; + source: Array> = new Array>(); + + constructor(startAt = 0) { + this.length = startAt; + } + + /** + * Make the vector look like it is this long + */ + setLength(length: number) { + if (length > this.length) { + // make the vector longer (filling with undefined) + this.length = length; + } else if (length < this.length) { + // make the array shorter + const sources: Array> = new Array>(); + for (const src of this.source) { + sources.push(src); + if (src.end > length) { + src.end = length; + break; + } + } + this.source = sources; + this.length = length; + } + } + + append(v: Vector): AppendedVectorInfo { + const info = { + start: this.length, + end: this.length + v.length, + values: v, + }; + this.length = info.end; + this.source.push(info); + return info; + } + + get(index: number): T { + for (let i = 0; i < this.source.length; i++) { + const src = this.source[i]; + if (index >= src.start && index < src.end) { + return src.values.get(index - src.start); + } + } + return (undefined as unknown) as T; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ArrayVector.ts b/packages/grafana-data/src/vector/ArrayVector.ts new file mode 100644 index 000000000000..63c614f1cc4d --- /dev/null +++ b/packages/grafana-data/src/vector/ArrayVector.ts @@ -0,0 +1,37 @@ +import { MutableVector } from '../types/vector'; + +export class ArrayVector implements MutableVector { + buffer: T[]; + + constructor(buffer?: T[]) { + this.buffer = buffer ? buffer : []; + } + + get length() { + return this.buffer.length; + } + + add(value: T) { + this.buffer.push(value); + } + + get(index: number): T { + return this.buffer[index]; + } + + set(index: number, value: T) { + this.buffer[index] = value; + } + + reverse() { + this.buffer.reverse(); + } + + toArray(): T[] { + return this.buffer; + } + + toJSON(): T[] { + return this.buffer; + } +} diff --git a/packages/grafana-data/src/utils/vector.test.ts b/packages/grafana-data/src/vector/CircularVector.test.ts similarity index 65% rename from packages/grafana-data/src/utils/vector.test.ts rename to packages/grafana-data/src/vector/CircularVector.test.ts index 1806020ed59f..abffd15846ba 100644 --- a/packages/grafana-data/src/utils/vector.test.ts +++ b/packages/grafana-data/src/vector/CircularVector.test.ts @@ -1,31 +1,4 @@ -import { ConstantVector, ScaledVector, ArrayVector, CircularVector, AppendedVectors } from './vector'; - -describe('Check Proxy Vector', () => { - it('should support constant values', () => { - const value = 3.5; - const v = new ConstantVector(value, 7); - expect(v.length).toEqual(7); - - expect(v.get(0)).toEqual(value); - expect(v.get(1)).toEqual(value); - - // Now check all of them - for (let i = 0; i < 10; i++) { - expect(v.get(i)).toEqual(value); - } - }); - - it('should support multiply operations', () => { - const source = new ArrayVector([1, 2, 3, 4]); - const scale = 2.456; - const v = new ScaledVector(source, scale); - expect(v.length).toEqual(source.length); - // expect(v.push(10)).toEqual(source.length); // not implemented - for (let i = 0; i < 10; i++) { - expect(v.get(i)).toEqual(source.get(i) * scale); - } - }); -}); +import { CircularVector } from './CircularVector'; describe('Check Circular Vector', () => { it('should append values', () => { @@ -156,24 +129,3 @@ describe('Check Circular Vector', () => { expect(v.toArray()).toEqual([3, 4, 5]); }); }); - -describe('Check Appending Vector', () => { - it('should transparently join them', () => { - const appended = new AppendedVectors(); - appended.append(new ArrayVector([1, 2, 3])); - appended.append(new ArrayVector([4, 5, 6])); - appended.append(new ArrayVector([7, 8, 9])); - expect(appended.length).toEqual(9); - - appended.setLength(5); - expect(appended.length).toEqual(5); - appended.append(new ArrayVector(['a', 'b', 'c'])); - expect(appended.length).toEqual(8); - expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); - - appended.setLength(2); - appended.setLength(6); - appended.append(new ArrayVector(['x', 'y', 'z'])); - expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); - }); -}); diff --git a/packages/grafana-data/src/vector/CircularVector.ts b/packages/grafana-data/src/vector/CircularVector.ts new file mode 100644 index 000000000000..f0eeb6579f03 --- /dev/null +++ b/packages/grafana-data/src/vector/CircularVector.ts @@ -0,0 +1,138 @@ +import { MutableVector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +interface CircularOptions { + buffer?: T[]; + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * Circular vector uses a single buffer to capture a stream of values + * overwriting the oldest value on add. + * + * This supports addting to the 'head' or 'tail' and will grow the buffer + * to match a configured capacity. + */ +export class CircularVector implements MutableVector { + private buffer: T[]; + private index: number; + private capacity: number; + private tail: boolean; + + constructor(options: CircularOptions) { + this.buffer = options.buffer || []; + this.capacity = this.buffer.length; + this.tail = 'head' !== options.append; + this.index = 0; + + this.add = this.getAddFunction(); + if (options.capacity) { + this.setCapacity(options.capacity); + } + } + + /** + * This gets the appropriate add function depending on the buffer state: + * * head vs tail + * * growing buffer vs overwriting values + */ + private getAddFunction() { + // When we are not at capacity, it should actually modify the buffer + if (this.capacity > this.buffer.length) { + if (this.tail) { + return (value: T) => { + this.buffer.push(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } else { + return (value: T) => { + this.buffer.unshift(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } + } + + if (this.tail) { + return (value: T) => { + this.buffer[this.index] = value; + this.index = (this.index + 1) % this.buffer.length; + }; + } + + // Append values to the head + return (value: T) => { + let idx = this.index - 1; + if (idx < 0) { + idx = this.buffer.length - 1; + } + this.buffer[idx] = value; + this.index = idx; + }; + } + + setCapacity(v: number) { + if (this.capacity === v) { + return; + } + // Make a copy so it is in order and new additions can be at the head or tail + const copy = this.toArray(); + if (v > this.length) { + this.buffer = copy; + } else if (v < this.capacity) { + // Shrink the buffer + const delta = this.length - v; + if (this.tail) { + this.buffer = copy.slice(delta, copy.length); // Keep last items + } else { + this.buffer = copy.slice(0, copy.length - delta); // Keep first items + } + } + this.capacity = v; + this.index = 0; + this.add = this.getAddFunction(); + } + + setAppendMode(mode: 'head' | 'tail') { + const tail = 'head' !== mode; + if (tail !== this.tail) { + this.buffer = this.toArray().reverse(); + this.index = 0; + this.tail = tail; + this.add = this.getAddFunction(); + } + } + + reverse() { + this.buffer.reverse(); + } + + /** + * Add the value to the buffer + */ + add: (value: T) => void; + + get(index: number) { + return this.buffer[(index + this.index) % this.buffer.length]; + } + + set(index: number, value: T) { + this.buffer[(index + this.index) % this.buffer.length] = value; + } + + get length() { + return this.buffer.length; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ConstantVector.test.ts b/packages/grafana-data/src/vector/ConstantVector.test.ts new file mode 100644 index 000000000000..b1b942209ee3 --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.test.ts @@ -0,0 +1,17 @@ +import { ConstantVector } from './ConstantVector'; + +describe('ConstantVector', () => { + it('should support constant values', () => { + const value = 3.5; + const v = new ConstantVector(value, 7); + expect(v.length).toEqual(7); + + expect(v.get(0)).toEqual(value); + expect(v.get(1)).toEqual(value); + + // Now check all of them + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(value); + } + }); +}); diff --git a/packages/grafana-data/src/vector/ConstantVector.ts b/packages/grafana-data/src/vector/ConstantVector.ts new file mode 100644 index 000000000000..07b3beddc19c --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.ts @@ -0,0 +1,22 @@ +import { Vector } from '../types/vector'; + +export class ConstantVector implements Vector { + constructor(private value: T, private len: number) {} + + get length() { + return this.len; + } + + get(index: number): T { + return this.value; + } + + toArray(): T[] { + const arr = new Array(this.length); + return arr.fill(this.value); + } + + toJSON(): T[] { + return this.toArray(); + } +} diff --git a/packages/grafana-data/src/vector/ScaledVector.test.ts b/packages/grafana-data/src/vector/ScaledVector.test.ts new file mode 100644 index 000000000000..b39511355948 --- /dev/null +++ b/packages/grafana-data/src/vector/ScaledVector.test.ts @@ -0,0 +1,15 @@ +import { ArrayVector } from './ArrayVector'; +import { ScaledVector } from './ScaledVector'; + +describe('ScaledVector', () => { + it('should support multiply operations', () => { + const source = new ArrayVector([1, 2, 3, 4]); + const scale = 2.456; + const v = new ScaledVector(source, scale); + expect(v.length).toEqual(source.length); + // expect(v.push(10)).toEqual(source.length); // not implemented + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(source.get(i) * scale); + } + }); +}); diff --git a/packages/grafana-data/src/vector/ScaledVector.ts b/packages/grafana-data/src/vector/ScaledVector.ts new file mode 100644 index 000000000000..0656291d7d07 --- /dev/null +++ b/packages/grafana-data/src/vector/ScaledVector.ts @@ -0,0 +1,22 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +export class ScaledVector implements Vector { + constructor(private source: Vector, private scale: number) {} + + get length(): number { + return this.source.length; + } + + get(index: number): number { + return this.source.get(index) * this.scale; + } + + toArray(): number[] { + return vectorToArray(this); + } + + toJSON(): number[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/SortedVector.ts b/packages/grafana-data/src/vector/SortedVector.ts new file mode 100644 index 000000000000..33b97f6778fa --- /dev/null +++ b/packages/grafana-data/src/vector/SortedVector.ts @@ -0,0 +1,25 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +/** + * Values are returned in the order defined by the input parameter + */ +export class SortedVector implements Vector { + constructor(private source: Vector, private order: number[]) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.source.get(this.order[index]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/index.ts b/packages/grafana-data/src/vector/index.ts new file mode 100644 index 000000000000..392cb3796108 --- /dev/null +++ b/packages/grafana-data/src/vector/index.ts @@ -0,0 +1,6 @@ +export * from './AppendedVectors'; +export * from './ArrayVector'; +export * from './CircularVector'; +export * from './ConstantVector'; +export * from './ScaledVector'; +export * from './SortedVector'; diff --git a/packages/grafana-data/src/vector/vectorToArray.ts b/packages/grafana-data/src/vector/vectorToArray.ts new file mode 100644 index 000000000000..34212651ab83 --- /dev/null +++ b/packages/grafana-data/src/vector/vectorToArray.ts @@ -0,0 +1,9 @@ +import { Vector } from '../types/vector'; + +export function vectorToArray(v: Vector): T[] { + const arr: T[] = []; + for (let i = 0; i < v.length; i++) { + arr[i] = v.get(i); + } + return arr; +} diff --git a/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx index b44c130b9b9a..9b9561816fd7 100644 --- a/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx +++ b/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx @@ -1,5 +1,5 @@ import React, { useContext } from 'react'; -import { FilterFieldsByNameTransformerOptions, DataTransformerID, dataTransformers, KeyValue } from '@grafana/data'; +import { FilterFieldsByNameTransformerOptions, DataTransformerID, transformersRegistry, KeyValue } from '@grafana/data'; import { TransformerUIProps, TransformerUIRegistyItem } from './types'; import { ThemeContext } from '../../themes/ThemeContext'; import { css, cx } from 'emotion'; @@ -157,7 +157,7 @@ const FilterPill: React.FC = ({ label, selected, onClick }) => export const filterFieldsByNameTransformRegistryItem: TransformerUIRegistyItem = { id: DataTransformerID.filterFieldsByName, component: FilterByNameTransformerEditor, - transformer: dataTransformers.get(DataTransformerID.filterFieldsByName), + transformer: transformersRegistry.get(DataTransformerID.filterFieldsByName), name: 'Filter by name', description: 'UI for filter by name transformation', }; diff --git a/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx index d12e9f86e469..484dd10077f3 100644 --- a/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx +++ b/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { StatsPicker } from '../StatsPicker/StatsPicker'; -import { ReduceTransformerOptions, DataTransformerID, ReducerID } from '@grafana/data'; +import { ReduceTransformerOptions, DataTransformerID, ReducerID, transformersRegistry } from '@grafana/data'; import { TransformerUIRegistyItem, TransformerUIProps } from './types'; -import { dataTransformers } from '@grafana/data'; // TODO: Minimal implementation, needs some <3 export const ReduceTransformerEditor: React.FC> = ({ @@ -29,7 +28,7 @@ export const ReduceTransformerEditor: React.FC = { id: DataTransformerID.reduce, component: ReduceTransformerEditor, - transformer: dataTransformers.get(DataTransformerID.reduce), + transformer: transformersRegistry.get(DataTransformerID.reduce), name: 'Reduce', description: 'UI for reduce transformation', }; diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 30860f5d7721..efe8ee84eca7 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -1,4 +1,4 @@ -jest.mock('@grafana/data/src/utils/moment_wrapper', () => ({ +jest.mock('@grafana/data/src/datetime/moment_wrapper', () => ({ dateTime: (ts: any) => { return { valueOf: () => ts, From bf40849a2c04f952f303eb22f4b21136f0069f03 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki Date: Tue, 17 Sep 2019 10:17:18 +0300 Subject: [PATCH 68/87] Add directions for more details provided when not anymore on issue triage (#19116) --- ISSUE_TRIAGE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ISSUE_TRIAGE.md b/ISSUE_TRIAGE.md index 004419abe121..cee5e8567eff 100644 --- a/ISSUE_TRIAGE.md +++ b/ISSUE_TRIAGE.md @@ -183,6 +183,8 @@ If the author does not respond to the requested information within the timespan When you feel you have all the information needed you're ready to [categorizing the issue](#3-categorizing-an-issue). +If you receive a notification with additional information provided but you are not anymore on issue triage and you feel you do not have time to handle it, you should delegate it to the current person on issue triage. + ## 3. Categorizing an issue An issue can have multiple of the following labels. Typically, a properly categorized issue should at least have: From 80592e336122d41562d4a2b44e6c4973f48fa16a Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 17 Sep 2019 09:32:24 +0200 Subject: [PATCH 69/87] Metrics: Adds setting for turning off total stats metrics (#19142) Don't update total stats metrics if reporting is disabled. New setting disable_total_stats for turning off update of total stats (stat_totals_*) metrics. Ref #19137 --- conf/defaults.ini | 6 ++- conf/sample.ini | 2 + docs/sources/installation/configuration.md | 3 ++ pkg/infra/usagestats/usage_stats.go | 4 ++ pkg/infra/usagestats/usage_stats_test.go | 43 ++++++++++++++++++++++ pkg/setting/setting.go | 2 + 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 52b9a4f2ed36..c825f2374d2b 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -593,8 +593,10 @@ enabled = true #################################### Internal Grafana Metrics ############ # Metrics available at HTTP API Url /metrics [metrics] -enabled = true -interval_seconds = 10 +enabled = true +interval_seconds = 10 +# Disable total stats (stat_totals_*) metrics to be generated +disable_total_stats = false #If both are set, basic auth will be required for the metrics endpoint. basic_auth_username = diff --git a/conf/sample.ini b/conf/sample.ini index 31468bfa15bc..b215a4fb02d8 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -526,6 +526,8 @@ [metrics] # Disable / Enable internal metrics ;enabled = true +# Disable total stats (stat_totals_*) metrics to be generated +;disable_total_stats = false # Publish interval ;interval_seconds = 10 diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 3c4eceaaab68..f4a2a61a468d 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -548,6 +548,9 @@ If set configures the username to use for basic authentication on the metrics en ### basic_auth_password If set configures the password to use for basic authentication on the metrics endpoint. +### disable_total_stats +If set to `true`, then total stats generation (`stat_totals_*` metrics) is disabled. The default is `false`. + ### interval_seconds Flush/Write interval when sending metrics to external TSDB. Defaults to 10s. diff --git a/pkg/infra/usagestats/usage_stats.go b/pkg/infra/usagestats/usage_stats.go index 96bf7df096de..d6c165e766cd 100644 --- a/pkg/infra/usagestats/usage_stats.go +++ b/pkg/infra/usagestats/usage_stats.go @@ -155,6 +155,10 @@ func (uss *UsageStatsService) sendUsageStats(oauthProviders map[string]bool) { } func (uss *UsageStatsService) updateTotalStats() { + if !uss.Cfg.MetricsEndpointEnabled || uss.Cfg.MetricsEndpointDisableTotalStats { + return + } + statsQuery := models.GetSystemStatsQuery{} if err := uss.Bus.Dispatch(&statsQuery); err != nil { metricsLogger.Error("Failed to get system stats", "error", err) diff --git a/pkg/infra/usagestats/usage_stats_test.go b/pkg/infra/usagestats/usage_stats_test.go index 07f2df221924..45876fa304e0 100644 --- a/pkg/infra/usagestats/usage_stats_test.go +++ b/pkg/infra/usagestats/usage_stats_test.go @@ -264,6 +264,49 @@ func TestMetrics(t *testing.T) { ts.Close() }) }) + + Convey("Test update total stats", t, func() { + uss := &UsageStatsService{ + Bus: bus.New(), + Cfg: setting.NewCfg(), + } + uss.Cfg.MetricsEndpointEnabled = true + uss.Cfg.MetricsEndpointDisableTotalStats = false + getSystemStatsWasCalled := false + uss.Bus.AddHandler(func(query *models.GetSystemStatsQuery) error { + query.Result = &models.SystemStats{} + getSystemStatsWasCalled = true + return nil + }) + + Convey("should not update stats when metrics is disabled and total stats is disabled", func() { + uss.Cfg.MetricsEndpointEnabled = false + uss.Cfg.MetricsEndpointDisableTotalStats = true + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeFalse) + }) + + Convey("should not update stats when metrics is disabled and total stats enabled", func() { + uss.Cfg.MetricsEndpointEnabled = false + uss.Cfg.MetricsEndpointDisableTotalStats = false + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeFalse) + }) + + Convey("should not update stats when metrics is enabled and total stats disabled", func() { + uss.Cfg.MetricsEndpointEnabled = true + uss.Cfg.MetricsEndpointDisableTotalStats = true + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeFalse) + }) + + Convey("should update stats when metrics is enabled and total stats enabled", func() { + uss.Cfg.MetricsEndpointEnabled = true + uss.Cfg.MetricsEndpointDisableTotalStats = false + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeTrue) + }) + }) } func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a0dd67450865..a9f74a1ad70b 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -243,6 +243,7 @@ type Cfg struct { MetricsEndpointEnabled bool MetricsEndpointBasicAuthUsername string MetricsEndpointBasicAuthPassword string + MetricsEndpointDisableTotalStats bool PluginsEnableAlpha bool PluginsAppsSkipVerifyTLS bool DisableSanitizeHtml bool @@ -907,6 +908,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { if err != nil { return err } + cfg.MetricsEndpointDisableTotalStats = iniFile.Section("metrics").Key("disable_total_stats").MustBool(false) analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) From aaf93b2f77b807c98f543f1a44a35fe5f0ec09ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 17 Sep 2019 09:46:26 +0200 Subject: [PATCH 70/87] Feature: Encapsulated dynamic imports with error boundary and suspense (#19128) * Feature: Encapsulated dynamic imports with error boundary and suspense * Refactor: Changes after PR review * Align PageLoader and LoadingPlaceholder * Updated loading failed UI abit * Change Failed to Unable --- .../core/components/PageLoader/PageLoader.tsx | 4 +- .../app/core/components/SafeDynamicImport.tsx | 52 +++++++++++++ public/app/routes/routes.ts | 75 +++++++++++++------ 3 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 public/app/core/components/SafeDynamicImport.tsx diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx index 6deeabf9a413..ff4f4dc4c20c 100644 --- a/public/app/core/components/PageLoader/PageLoader.tsx +++ b/public/app/core/components/PageLoader/PageLoader.tsx @@ -1,4 +1,5 @@ import React, { FC } from 'react'; +import { LoadingPlaceholder } from '@grafana/ui'; interface Props { pageName?: string; @@ -8,8 +9,7 @@ const PageLoader: FC = ({ pageName = '' }) => { const loadingText = `Loading ${pageName}...`; return (
- -
{loadingText}
+
); }; diff --git a/public/app/core/components/SafeDynamicImport.tsx b/public/app/core/components/SafeDynamicImport.tsx new file mode 100644 index 000000000000..11eba3b7ef79 --- /dev/null +++ b/public/app/core/components/SafeDynamicImport.tsx @@ -0,0 +1,52 @@ +import React, { lazy, Suspense, FunctionComponent } from 'react'; +import { cx, css } from 'emotion'; +import { LoadingPlaceholder, ErrorBoundary, Button } from '@grafana/ui'; + +export const LoadingChunkPlaceHolder: FunctionComponent = () => ( +
+ +
+); + +function getAlertPageStyle() { + return css` + width: 508px; + margin: 128px auto; + `; +} + +export const SafeDynamicImport = (importStatement: Promise) => ({ ...props }) => { + const LazyComponent = lazy(() => importStatement); + return ( + + {({ error, errorInfo }) => { + if (!errorInfo) { + return ( + }> + + + ); + } + + return ( +
+

Unable to find application file

+
+

Grafana has likely been updated. Please try reloading the page.

+
+
+ +
+
+ {error && error.toString()} +
+ {errorInfo.componentStack} +
+
+ ); + }} +
+ ); +}; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 1c7f7821f2e4..b21243984bc3 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -1,7 +1,6 @@ import './dashboard_loaders'; import './ReactContainer'; import { applyRouteRegistrationHandlers } from './registry'; - // Pages import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl'; import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl'; @@ -10,10 +9,10 @@ import LdapPage from 'app/features/admin/ldap/LdapPage'; import LdapUserPage from 'app/features/admin/ldap/LdapUserPage'; import config from 'app/core/config'; import { route, ILocationProvider } from 'angular'; - // Types import { DashboardRouteInfo } from 'app/types'; import { LoginPage } from 'app/core/components/Login/LoginPage'; +import { SafeDynamicImport } from '../core/components/SafeDynamicImport'; /** @ngInject */ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locationProvider: ILocationProvider) { @@ -23,7 +22,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati // ones. That means angular ones could be navigated to in case there is a client side link some where. const importDashboardPage = () => - import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage'); + SafeDynamicImport(import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage')); $routeProvider .when('/', { @@ -79,7 +78,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, resolve: { component: () => - import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage'), + SafeDynamicImport( + import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + ), }, }) .when('/dashboard-solo/:type/:slug', { @@ -89,7 +90,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, resolve: { component: () => - import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage'), + SafeDynamicImport( + import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + ), }, }) .when('/dashboard/import', { @@ -101,7 +104,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', resolve: { component: () => - import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage'), + SafeDynamicImport( + import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage') + ), }, }) .when('/datasources/edit/:id/', { @@ -109,20 +114,27 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, // for tabs resolve: { component: () => - import(/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage'), + SafeDynamicImport( + import(/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage') + ), }, }) .when('/datasources/edit/:id/dashboards', { template: '', resolve: { component: () => - import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards'), + SafeDynamicImport( + import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards') + ), }, }) .when('/datasources/new', { template: '', resolve: { - component: () => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage'), + component: () => + SafeDynamicImport( + import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage') + ), }, }) .when('/dashboards', { @@ -138,13 +150,19 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/dashboards/f/:uid/:slug/permissions', { template: '', resolve: { - component: () => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions'), + component: () => + SafeDynamicImport( + import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions') + ), }, }) .when('/dashboards/f/:uid/:slug/settings', { template: '', resolve: { - component: () => import(/* webpackChunkName: "FolderSettingsPage"*/ 'app/features/folders/FolderSettingsPage'), + component: () => + SafeDynamicImport( + import(/* webpackChunkName: "FolderSettingsPage"*/ 'app/features/folders/FolderSettingsPage') + ), }, }) .when('/dashboards/f/:uid/:slug', { @@ -162,7 +180,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, resolve: { roles: () => (config.viewersCanEdit ? [] : ['Editor', 'Admin']), - component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'), + component: () => SafeDynamicImport(import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper')), }, }) .when('/a/:pluginId/', { @@ -170,13 +188,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', reloadOnSearch: false, resolve: { - component: () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/AppRootPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/AppRootPage')), }, }) .when('/org', { template: '', resolve: { - component: () => import(/* webpackChunkName: "OrgDetailsPage" */ '../features/org/OrgDetailsPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "OrgDetailsPage" */ '../features/org/OrgDetailsPage')), }, }) .when('/org/new', { @@ -186,7 +206,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/org/users', { template: '', resolve: { - component: () => import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage')), }, }) .when('/org/users/invite', { @@ -198,14 +219,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', resolve: { roles: () => ['Editor', 'Admin'], - component: () => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')), }, }) .when('/org/teams', { template: '', resolve: { roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']), - component: () => import(/* webpackChunkName: "TeamList" */ 'app/features/teams/TeamList'), + component: () => SafeDynamicImport(import(/* webpackChunkName: "TeamList" */ 'app/features/teams/TeamList')), }, }) .when('/org/teams/new', { @@ -217,7 +239,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', resolve: { roles: () => (config.editorsCanAdmin ? [] : ['Admin']), - component: () => import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages'), + component: () => SafeDynamicImport(import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages')), }, }) .when('/profile', { @@ -228,7 +250,10 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/profile/password', { template: '', resolve: { - component: () => import(/* webPackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage'), + component: () => + SafeDynamicImport( + import(/* webPackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage') + ), }, }) .when('/profile/select-org', { @@ -278,7 +303,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/admin/stats', { template: '', resolve: { - component: () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats')), }, }) .when('/admin/ldap', { @@ -323,14 +349,16 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/plugins', { template: '', resolve: { - component: () => import(/* webpackChunkName: "PluginListPage" */ 'app/features/plugins/PluginListPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "PluginListPage" */ 'app/features/plugins/PluginListPage')), }, }) .when('/plugins/:pluginId/', { template: '', reloadOnSearch: false, // tabs from query parameters resolve: { - component: () => import(/* webpackChunkName: "PluginPage" */ '../features/plugins/PluginPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "PluginPage" */ '../features/plugins/PluginPage')), }, }) .when('/plugins/:pluginId/page/:slug', { @@ -350,7 +378,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', reloadOnSearch: false, resolve: { - component: () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList')), }, }) .when('/alerting/notifications', { From 3952083bdb076a644aa2adbd03b06ee48333e536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 17 Sep 2019 09:48:47 +0200 Subject: [PATCH 71/87] TimePicker: Fixes onBlur issue with FireFox on MacOS (#19154) Fixes: #18531 --- packages/grafana-ui/src/components/Select/ButtonSelect.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx index ab35c12a5998..fe4e7ef2a57d 100644 --- a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx @@ -13,11 +13,12 @@ const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => { const { label, className, iconClass } = buttonProps; return ( - +
); }; From 494b4aaf882f0f8be06da8c6a66105b45f9b960d Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Tue, 17 Sep 2019 11:27:55 +0300 Subject: [PATCH 72/87] LDAP: only show tab if LDAP is enabled (#19156) * LDAP: add nav link only if ldap enabled * LDAP: /admin/ldap required admin permissions --- pkg/api/api.go | 1 + pkg/api/index.go | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index f84299cbe624..f82f973a7216 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -55,6 +55,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index) r.Get("/admin/stats", reqGrafanaAdmin, hs.Index) + r.Get("/admin/ldap", reqGrafanaAdmin, hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) diff --git a/pkg/api/index.go b/pkg/api/index.go index c22f3498a6a0..1c87b0d84fa5 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -306,6 +306,19 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er }) if c.IsGrafanaAdmin { + adminNavLinks := []*dtos.NavLink{ + {Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"}, + {Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"}, + {Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"}, + {Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"}, + } + + if setting.LDAPEnabled { + adminNavLinks = append(adminNavLinks, &dtos.NavLink{ + Text: "LDAP", Id: "ldap", Url: setting.AppSubUrl + "/admin/ldap", Icon: "fa fa-fw fa-address-book-o", + }) + } + data.NavTree = append(data.NavTree, &dtos.NavLink{ Text: "Server Admin", SubTitle: "Manage all users & orgs", @@ -313,13 +326,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er Id: "admin", Icon: "gicon gicon-shield", Url: setting.AppSubUrl + "/admin/users", - Children: []*dtos.NavLink{ - {Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"}, - {Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"}, - {Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"}, - {Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"}, - {Text: "LDAP", Id: "ldap", Url: setting.AppSubUrl + "/admin/ldap", Icon: "fa fa-fw fa-address-book-o"}, - }, + Children: adminNavLinks, }) } From 359404eb778f5d5a17915a0687e8598b65cdde9d Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 17 Sep 2019 11:25:12 +0200 Subject: [PATCH 73/87] Explore: Update live tail buttons (#19143) --- package.json | 2 + .../RefreshPicker/RefreshPicker.tsx | 60 +++++-- .../RefreshPicker/_RefreshPicker.scss | 4 - .../grafana-ui/src/themes/ThemeContext.tsx | 15 +- packages/grafana-ui/src/themes/index.ts | 4 +- .../features/explore/ExploreTimeControls.tsx | 50 ++---- .../app/features/explore/ExploreToolbar.tsx | 150 ++++++++++-------- .../app/features/explore/LiveTailButton.tsx | 104 ++++++++++++ public/app/features/explore/LogsContainer.tsx | 5 +- .../app/features/explore/ResponsiveButton.tsx | 36 +++++ public/app/features/explore/RunButton.tsx | 52 ++++++ public/sass/pages/_explore.scss | 4 - yarn.lock | 17 +- 13 files changed, 365 insertions(+), 138 deletions(-) create mode 100644 public/app/features/explore/LiveTailButton.tsx create mode 100644 public/app/features/explore/ResponsiveButton.tsx create mode 100644 public/app/features/explore/RunButton.tsx diff --git a/package.json b/package.json index 2e6792aaee97..a53ab845d1ea 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/enzyme-adapter-react-16": "1.0.5", "@types/expect-puppeteer": "3.3.1", "@types/file-saver": "2.0.1", + "@types/hoist-non-react-statics": "3.3.0", "@types/is-hotkey": "0.1.1", "@types/jest": "24.0.13", "@types/jquery": "1.10.35", @@ -208,6 +209,7 @@ "eventemitter3": "2.0.3", "fast-text-encoding": "^1.0.0", "file-saver": "1.3.8", + "hoist-non-react-statics": "3.3.0", "immutable": "3.8.2", "is-hotkey": "0.1.4", "jquery": "3.4.1", diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 92a55d71ac07..72f1ae342146 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -1,24 +1,48 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { SelectableValue } from '@grafana/data'; +import { css } from 'emotion'; import { Tooltip } from '../Tooltip/Tooltip'; import { ButtonSelect } from '../Select/ButtonSelect'; +import memoizeOne from 'memoize-one'; +import { GrafanaTheme } from '../../types'; +import { withTheme } from '../../themes'; export const offOption = { label: 'Off', value: '' }; export const liveOption = { label: 'Live', value: 'LIVE' }; export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; export const isLive = (refreshInterval: string): boolean => refreshInterval === liveOption.value; +const getStyles = memoizeOne((theme: GrafanaTheme) => { + return { + selectButton: css` + label: selectButton; + .select-button-value { + color: ${theme.colors.orange}; + } + `, + }; +}); + export interface Props { intervals?: string[]; - onRefresh: () => any; + onRefresh?: () => any; onIntervalChanged: (interval: string) => void; value?: string; - tooltip: string; + tooltip?: string; hasLiveOption?: boolean; + // You can supply your own refresh button element. In that case onRefresh and tooltip are ignored. + refreshButton?: React.ReactNode; + buttonSelectClassName?: string; + theme: GrafanaTheme; } -export class RefreshPicker extends PureComponent { +export class RefreshPickerBase extends PureComponent { + // Make it exported as static properties to be easier to access. The global exports need to be accessed by direct + // import of this source file which won't work if this was installed as package. + static offOption = offOption; + static liveOption = liveOption; + constructor(props: Props) { super(props); } @@ -46,10 +70,11 @@ export class RefreshPicker extends PureComponent { }; render() { - const { onRefresh, intervals, tooltip, value } = this.props; + const { onRefresh, intervals, tooltip, value, refreshButton, buttonSelectClassName, theme } = this.props; const options = this.intervalsToOptions(intervals); const currentValue = value || ''; const selectedValue = options.find(item => item.value === currentValue) || offOption; + const styles = getStyles(theme); const cssClasses = classNames({ 'refresh-picker': true, @@ -60,13 +85,20 @@ export class RefreshPicker extends PureComponent { return (
- - - + {refreshButton ? ( + refreshButton + ) : ( + + + + )} { ); } } + +export const RefreshPicker = withTheme< + Props, + { + offOption: typeof RefreshPickerBase.offOption; + liveOption: typeof RefreshPickerBase.liveOption; + } +>(RefreshPickerBase); diff --git a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss index 3dff67217408..0146147d79a2 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss +++ b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss @@ -20,10 +20,6 @@ width: 100%; } - .select-button-value { - color: $orange; - } - &--off { .select-button-value { display: none; diff --git a/packages/grafana-ui/src/themes/ThemeContext.tsx b/packages/grafana-ui/src/themes/ThemeContext.tsx index 11b3d79481aa..ea9fcb1502ff 100644 --- a/packages/grafana-ui/src/themes/ThemeContext.tsx +++ b/packages/grafana-ui/src/themes/ThemeContext.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; + import { getTheme } from './getTheme'; import { GrafanaThemeType, Themeable } from '../types/theme'; @@ -8,13 +10,18 @@ type Subtract = Omit; // Use Grafana Dark theme by default export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark)); -export const withTheme =

(Component: React.ComponentType

) => { +export const withTheme =

(Component: React.ComponentType

) => { const WithTheme: React.FunctionComponent> = props => { // @ts-ignore return {theme => }; }; WithTheme.displayName = `WithTheme(${Component.displayName})`; - - return WithTheme; + hoistNonReactStatics(WithTheme, Component); + type Hoisted = typeof WithTheme & S; + return WithTheme as Hoisted; }; + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts index 6d3e41e867e5..89b8ab2877da 100644 --- a/packages/grafana-ui/src/themes/index.ts +++ b/packages/grafana-ui/src/themes/index.ts @@ -1,5 +1,5 @@ -import { ThemeContext, withTheme } from './ThemeContext'; +import { ThemeContext, withTheme, useTheme } from './ThemeContext'; import { getTheme, mockTheme } from './getTheme'; import { selectThemeVariant } from './selectThemeVariant'; -export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant }; +export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme }; diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index 01c9f5139757..53b1fbce389a 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -8,7 +8,7 @@ import { TimeRange, TimeOption, TimeZone, RawTimeRange, dateTimeForTimeZone } fr // State // Components -import { TimePicker, RefreshPicker, SetInterval } from '@grafana/ui'; +import { TimePicker } from '@grafana/ui'; // Utils & Services import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker'; @@ -16,14 +16,8 @@ import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePick export interface Props { exploreId: ExploreId; - hasLiveOption: boolean; - isLive: boolean; - loading: boolean; range: TimeRange; - refreshInterval: string; timeZone: TimeZone; - onRunQuery: () => void; - onChangeRefreshInterval: (interval: string) => void; onChangeTime: (range: RawTimeRange) => void; } @@ -73,40 +67,18 @@ export class ExploreTimeControls extends Component { }; render() { - const { - hasLiveOption, - isLive, - loading, - range, - refreshInterval, - timeZone, - onRunQuery, - onChangeRefreshInterval, - } = this.props; + const { range, timeZone } = this.props; return ( - <> - {!isLive && ( - - )} - - - {refreshInterval && } - + ); } } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index ba57b3030f7a..1687e1b04759 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -5,8 +5,17 @@ import { hot } from 'react-hot-loader'; import memoizeOne from 'memoize-one'; import classNames from 'classnames'; -import { ExploreId, ExploreMode } from 'app/types/explore'; -import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton, DataQuery, Tooltip, ButtonSelect } from '@grafana/ui'; +import { ExploreId, ExploreItemState, ExploreMode } from 'app/types/explore'; +import { + DataSourceSelectItem, + ToggleButtonGroup, + ToggleButton, + DataQuery, + Tooltip, + ButtonSelect, + RefreshPicker, + SetInterval, +} from '@grafana/ui'; import { RawTimeRange, TimeZone, TimeRange, SelectableValue } from '@grafana/data'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; @@ -20,44 +29,15 @@ import { changeMode, clearOrigin, } from './state/actions'; +import { changeRefreshIntervalAction, setPausedStateAction } from './state/actionTypes'; import { updateLocation } from 'app/core/actions'; import { getTimeZone } from '../profile/state/selectors'; import { getDashboardSrv } from '../dashboard/services/DashboardSrv'; import kbn from '../../core/utils/kbn'; import { ExploreTimeControls } from './ExploreTimeControls'; - -enum IconSide { - left = 'left', - right = 'right', -} - -const createResponsiveButton = (options: { - splitted: boolean; - title: string; - onClick: () => void; - buttonClassName?: string; - iconClassName?: string; - iconSide?: IconSide; - disabled?: boolean; -}) => { - const defaultOptions = { - iconSide: IconSide.left, - }; - const props = { ...options, defaultOptions }; - const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props; - - return ( - - ); -}; +import { LiveTailButton } from './LiveTailButton'; +import { ResponsiveButton } from './ResponsiveButton'; +import { RunButton } from './RunButton'; interface OwnProps { exploreId: ExploreId; @@ -77,6 +57,7 @@ interface StateProps { selectedModeOption: SelectableValue; hasLiveOption: boolean; isLive: boolean; + isPaused: boolean; originPanelId: number; queries: DataQuery[]; } @@ -91,6 +72,8 @@ interface DispatchProps { changeMode: typeof changeMode; clearOrigin: typeof clearOrigin; updateLocation: typeof updateLocation; + changeRefreshIntervalAction: typeof changeRefreshIntervalAction; + setPausedStateAction: typeof setPausedStateAction; } type Props = StateProps & DispatchProps & OwnProps; @@ -147,6 +130,28 @@ export class UnConnectedExploreToolbar extends PureComponent { }); }; + stopLive = () => { + const { exploreId } = this.props; + // TODO referencing this from perspective of refresh picker when there is designated button for it now is not + // great. Needs another refactor. + this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.offOption.value }); + }; + + startLive = () => { + const { exploreId } = this.props; + this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.liveOption.value }); + }; + + pauseLive = () => { + const { exploreId } = this.props; + this.props.setPausedStateAction({ exploreId, isPaused: true }); + }; + + resumeLive = () => { + const { exploreId } = this.props; + this.props.setPausedStateAction({ exploreId, isPaused: false }); + }; + render() { const { datasourceMissing, @@ -165,6 +170,7 @@ export class UnConnectedExploreToolbar extends PureComponent { selectedModeOption, hasLiveOption, isLive, + isPaused, originPanelId, } = this.props; @@ -249,30 +255,25 @@ export class UnConnectedExploreToolbar extends PureComponent { {exploreId === 'left' && !splitted ? (

- {createResponsiveButton({ - splitted, - title: 'Split', - onClick: split, - iconClassName: 'fa fa-fw fa-columns icon-margin-right', - iconSide: IconSide.left, - disabled: isLive, - })} +
) : null} -
- -
+ {!isLive && ( +
+ +
+ )}
- {createResponsiveButton({ - splitted, - title: 'Run Query', - onClick: this.onRunQuery, - buttonClassName: 'navbar-button--secondary', - iconClassName: - loading && !isLive ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-level-down fa-fw run-icon', - iconSide: IconSide.right, - })} + + {refreshInterval && }
+ + {hasLiveOption && ( + + )}
@@ -334,7 +346,7 @@ const getModeOptionsMemoized = memoizeOne( const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => { const splitted = state.explore.split; - const exploreItem = state.explore[exploreId]; + const exploreItem: ExploreItemState = state.explore[exploreId]; const { datasourceInstance, datasourceMissing, @@ -345,6 +357,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps supportedModes, mode, isLive, + isPaused, originPanelId, queries, } = exploreItem; @@ -369,6 +382,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps selectedModeOption, hasLiveOption, isLive, + isPaused, originPanelId, queries, }; @@ -384,6 +398,8 @@ const mapDispatchToProps: DispatchProps = { split: splitOpen, changeMode: changeMode, clearOrigin, + changeRefreshIntervalAction, + setPausedStateAction, }; export const ExploreToolbar = hot(module)( diff --git a/public/app/features/explore/LiveTailButton.tsx b/public/app/features/explore/LiveTailButton.tsx new file mode 100644 index 000000000000..25897536b710 --- /dev/null +++ b/public/app/features/explore/LiveTailButton.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import classNames from 'classnames'; +import { css } from 'emotion'; +import memoizeOne from 'memoize-one'; +import { GrafanaTheme, GrafanaThemeType, useTheme } from '@grafana/ui'; +import tinycolor from 'tinycolor2'; + +const orangeDark = '#FF780A'; +const orangeDarkLighter = tinycolor(orangeDark) + .lighten(10) + .toString(); +const orangeLight = '#ED5700'; +const orangeLightLighter = tinycolor(orangeLight) + .lighten(10) + .toString(); + +const getStyles = memoizeOne((theme: GrafanaTheme) => { + const orange = theme.type === GrafanaThemeType.Dark ? orangeDark : orangeLight; + const orangeLighter = theme.type === GrafanaThemeType.Dark ? orangeDarkLighter : orangeLightLighter; + const textColor = theme.type === GrafanaThemeType.Dark ? theme.colors.white : theme.colors.black; + + return { + noRightBorderStyle: css` + label: noRightBorderStyle; + border-right: 0; + `, + isLive: css` + label: isLive; + border-color: ${orange}; + color: ${orange}; + background: transparent; + &:focus { + border-color: ${orange}; + color: ${orange}; + } + &:active, + &:hover { + border-color: ${orangeLighter}; + color: ${orangeLighter}; + } + `, + isPaused: css` + label: isPaused; + border-color: ${orange}; + background: transparent; + animation: pulse 2s ease-out 0s infinite normal forwards; + &:focus { + border-color: ${orange}; + } + &:active, + &:hover { + border-color: ${orangeLighter}; + } + @keyframes pulse { + 0% { + color: ${textColor}; + } + 50% { + color: ${orange}; + } + 100% { + color: ${textColor}; + } + } + `, + }; +}); + +type LiveTailButtonProps = { + start: () => void; + stop: () => void; + pause: () => void; + resume: () => void; + isLive: boolean; + isPaused: boolean; +}; +export function LiveTailButton(props: LiveTailButtonProps) { + const { start, pause, resume, isLive, isPaused, stop } = props; + const theme = useTheme(); + const styles = getStyles(theme); + + const onClickMain = isLive ? (isPaused ? resume : pause) : start; + + return ( +
+ + {isLive && ( + + )} +
+ ); +} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 42d61d9b8bdc..9d780b05d57f 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { DataSourceApi, Collapse } from '@grafana/ui'; +import { DataSourceApi, Collapse, RefreshPicker } from '@grafana/ui'; import { RawTimeRange, @@ -26,7 +26,6 @@ import { import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors'; import { getTimeZone } from '../profile/state/selectors'; import { LiveLogsWithTheme } from './LiveLogs'; -import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; import { Logs } from './Logs'; interface LogsContainerProps { @@ -65,7 +64,7 @@ export class LogsContainer extends PureComponent { onStopLive = () => { const { exploreId } = this.props; - this.props.stopLive({ exploreId, refreshInterval: offOption.value }); + this.props.stopLive({ exploreId, refreshInterval: RefreshPicker.offOption.value }); }; onPause = () => { diff --git a/public/app/features/explore/ResponsiveButton.tsx b/public/app/features/explore/ResponsiveButton.tsx new file mode 100644 index 000000000000..33aa2fedebf3 --- /dev/null +++ b/public/app/features/explore/ResponsiveButton.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +export enum IconSide { + left = 'left', + right = 'right', +} + +type Props = { + splitted: boolean; + title: string; + onClick: () => void; + buttonClassName?: string; + iconClassName?: string; + iconSide?: IconSide; + disabled?: boolean; +}; + +export const ResponsiveButton = (props: Props) => { + const defaultProps = { + iconSide: IconSide.left, + }; + props = { ...defaultProps, ...props }; + const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props; + + return ( + + ); +}; diff --git a/public/app/features/explore/RunButton.tsx b/public/app/features/explore/RunButton.tsx new file mode 100644 index 000000000000..b704d584cbac --- /dev/null +++ b/public/app/features/explore/RunButton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { RefreshPicker } from '@grafana/ui'; +import memoizeOne from 'memoize-one'; +import { css } from 'emotion'; + +import { ResponsiveButton } from './ResponsiveButton'; + +const getStyles = memoizeOne(() => { + return { + selectButtonOverride: css` + label: selectButtonOverride; + .select-button-value { + color: white !important; + } + `, + }; +}); + +type Props = { + splitted: boolean; + loading: boolean; + onRun: () => void; + refreshInterval: string; + onChangeRefreshInterval: (interval: string) => void; + showDropdown: boolean; +}; + +export function RunButton(props: Props) { + const { splitted, loading, onRun, onChangeRefreshInterval, refreshInterval, showDropdown } = props; + const styles = getStyles(); + const runButton = ( + + ); + + if (showDropdown) { + return ( + + ); + } + return runButton; +} diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index df924a14bde8..df8c8b1d96c6 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -6,10 +6,6 @@ margin-left: 0.25em; } -.run-icon { - transform: rotate(90deg); -} - .datasource-picker { .ds-picker { min-width: 200px; diff --git a/yarn.lock b/yarn.lock index 4f0afd752ad9..3493e5c28db6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3048,6 +3048,13 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hoist-non-react-statics@3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#a59c0c995cc885bef1b8ec2241b114f9b35b517b" + integrity sha512-O2OGyW9wlO2bbDmZRH17MecArQfsIa1g//ve2IJk6BnmwEglFz5kdhP1BlgeqjVNH5IHIhsc83DWFo8StCe8+Q== + dependencies: + "@types/react" "*" + "@types/hoist-non-react-statics@^3.3.0": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" @@ -9246,16 +9253,16 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^2.3.1: - version "2.5.5" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" - -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@3.3.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" dependencies: react-is "^16.7.0" +hoist-non-react-statics@^2.3.1: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" From b5f0a5d5caa70055b030f9efe38b96bf88bccfef Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki Date: Tue, 17 Sep 2019 12:29:43 +0300 Subject: [PATCH 74/87] Api: Readonly datasources should not be created via the API (#19006) * Readonly datasources should not be created via the API * Return correct `ReadOnly` value in GetDataSourceByName --- pkg/api/datasources.go | 1 - pkg/models/datasource.go | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index e76146140764..1fce9895e890 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -231,7 +231,6 @@ func GetDataSourceByName(c *m.ReqContext) Response { } dtos := convertModelToDtos(query.Result) - dtos.ReadOnly = true return JSON(200, &dtos) } diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 6df4dcb34573..9c8156f2a3ae 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -144,9 +144,9 @@ type AddDataSourceCommand struct { IsDefault bool `json:"isDefault"` JsonData *simplejson.Json `json:"jsonData"` SecureJsonData map[string]string `json:"secureJsonData"` - ReadOnly bool `json:"readOnly"` - OrgId int64 `json:"-"` + OrgId int64 `json:"-"` + ReadOnly bool `json:"-"` Result *DataSource } @@ -168,10 +168,10 @@ type UpdateDataSourceCommand struct { JsonData *simplejson.Json `json:"jsonData"` SecureJsonData map[string]string `json:"secureJsonData"` Version int `json:"version"` - ReadOnly bool `json:"readOnly"` - OrgId int64 `json:"-"` - Id int64 `json:"-"` + OrgId int64 `json:"-"` + Id int64 `json:"-"` + ReadOnly bool `json:"-"` Result *DataSource } From 88051258e9e31bfd6dbd4c1dc4aa72066b5d707a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 17 Sep 2019 11:35:40 +0200 Subject: [PATCH 75/87] Azure Monitor: Add support for cross resource queries (#19115) * Add new query mode picker with different states for each query. Also really simple migration script * Populate cross resource dropdowns * Cleanup. Handle change events * Add multi select picker for subscriptions * Fix markup issue * Prepare for new query mode * More cleanup * Handle multiple queries both in ds and backend * Refactoring * Improve migration * Add support for multiselect display name * Use multiselect also for locations and resources * Add more typings * Fix migrations * Custom multiselect built for array of options instead of variables * Add url builder test * fix datasource tests * UI fixes * Improve query editor init * Fix brokens tests * Cleanup * Fix tslint issue * Change query mode display name * Make sure alerting works for single queries * Friendly error for multi resources * Add temporary typings --- .../zero-datasources/placeholder-for-git | 0 .../azuremonitor/azuremonitor-datasource.go | 44 +- .../azuremonitor-datasource_test.go | 23 +- .../azure_monitor_datasource.test.ts | 12 +- .../azure_monitor/azure_monitor_datasource.ts | 250 +++++++-- .../azure_monitor/response_parser.ts | 5 +- .../azure_monitor/url_builder.test.ts | 42 +- .../azure_monitor/url_builder.ts | 14 +- .../datasource.ts | 19 +- .../migrations.ts | 15 + .../multi-select.directive.ts | 169 ++++++ .../partials/multi-select.directive.html | 24 + .../partials/query.editor.html | 140 +++-- .../query_ctrl.test.ts | 58 +- .../query_ctrl.ts | 506 +++++++++++++----- .../grafana-azure-monitor-datasource/types.ts | 29 +- 16 files changed, 1039 insertions(+), 311 deletions(-) delete mode 100644 pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/migrations.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/multi-select.directive.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html diff --git a/pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git b/pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go index 527313b0260d..ec2a4ea2cd9c 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go @@ -60,7 +60,11 @@ func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, ori if err != nil { queryRes.Error = err } - result.Results[query.RefID] = queryRes + if val, ok := result.Results[query.RefID]; ok { + val.Series = append(result.Results[query.RefID].Series, queryRes.Series...) + } else { + result.Results[query.RefID] = queryRes + } } return result, nil @@ -84,11 +88,22 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange * azureMonitorTarget := query.Model.Get("azureMonitor").MustMap() azlog.Debug("AzureMonitor", "target", azureMonitorTarget) + queryMode := fmt.Sprintf("%v", azureMonitorTarget["queryMode"]) + if queryMode == "crossResource" { + return nil, fmt.Errorf("Alerting not supported for multiple resource queries") + } + + var azureMonitorData map[string]interface{} + if queryMode == "singleResource" { + azureMonitorData = azureMonitorTarget["data"].(map[string]interface{})[queryMode].(map[string]interface{}) + } else { + azureMonitorData = azureMonitorTarget + } urlComponents := map[string]string{} urlComponents["subscription"] = fmt.Sprintf("%v", query.Model.Get("subscription").MustString()) - urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorTarget["resourceGroup"]) - urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorTarget["metricDefinition"]) - urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorTarget["resourceName"]) + urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorData["resourceGroup"]) + urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorData["metricDefinition"]) + urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorData["resourceName"]) ub := urlBuilder{ DefaultSubscription: query.DataSource.JsonData.Get("subscriptionId").MustString(), @@ -100,12 +115,12 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange * azureURL := ub.Build() alias := "" - if val, ok := azureMonitorTarget["alias"]; ok { + if val, ok := azureMonitorData["alias"]; ok { alias = fmt.Sprintf("%v", val) } - timeGrain := fmt.Sprintf("%v", azureMonitorTarget["timeGrain"]) - timeGrains := azureMonitorTarget["allowedTimeGrainsMs"] + timeGrain := fmt.Sprintf("%v", azureMonitorData["timeGrain"]) + timeGrains := azureMonitorData["allowedTimeGrainsMs"] if timeGrain == "auto" { timeGrain, err = e.setAutoTimeGrain(query.IntervalMs, timeGrains) if err != nil { @@ -117,13 +132,16 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange * params.Add("api-version", "2018-01-01") params.Add("timespan", fmt.Sprintf("%v/%v", startTime.UTC().Format(time.RFC3339), endTime.UTC().Format(time.RFC3339))) params.Add("interval", timeGrain) - params.Add("aggregation", fmt.Sprintf("%v", azureMonitorTarget["aggregation"])) - params.Add("metricnames", fmt.Sprintf("%v", azureMonitorTarget["metricName"])) - params.Add("metricnamespace", fmt.Sprintf("%v", azureMonitorTarget["metricNamespace"])) + params.Add("aggregation", fmt.Sprintf("%v", azureMonitorData["aggregation"])) + params.Add("metricnames", fmt.Sprintf("%v", azureMonitorData["metricName"])) + + if val, ok := azureMonitorData["metricNamespace"]; ok { + params.Add("metricnamespace", fmt.Sprintf("%v", val)) + } - dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimension"])) - dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimensionFilter"])) - if azureMonitorTarget["dimension"] != nil && azureMonitorTarget["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 && dimension != "None" { + dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorData["dimension"])) + dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorData["dimensionFilter"])) + if azureMonitorData["dimension"] != nil && azureMonitorData["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 && dimension != "None" { params.Add("$filter", fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter)) } diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go index ada82190d113..a748d9887658 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go @@ -36,15 +36,20 @@ func TestAzureMonitorDatasource(t *testing.T) { Model: simplejson.NewFromAny(map[string]interface{}{ "subscription": "12345678-aaaa-bbbb-cccc-123456789abc", "azureMonitor": map[string]interface{}{ - "timeGrain": "PT1M", - "aggregation": "Average", - "resourceGroup": "grafanastaging", - "resourceName": "grafana", - "metricDefinition": "Microsoft.Compute/virtualMachines", - "metricNamespace": "Microsoft.Compute-virtualMachines", - "metricName": "Percentage CPU", - "alias": "testalias", - "queryType": "Azure Monitor", + "queryMode": "singleResource", + "data": map[string]interface{}{ + "singleResource": map[string]interface{}{ + "timeGrain": "PT1M", + "aggregation": "Average", + "resourceGroup": "grafanastaging", + "resourceName": "grafana", + "metricDefinition": "Microsoft.Compute/virtualMachines", + "metricNamespace": "Microsoft.Compute-virtualMachines", + "metricName": "Percentage CPU", + "alias": "testalias", + "queryType": "Azure Monitor", + }, + }, }, }), RefId: "A", diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts index 4e262044f65e..69a542bb7504 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts @@ -918,8 +918,8 @@ describe('AzureMonitorDatasource', () => { 'nodeapp', 'microsoft.insights/components', 'resource1', - 'default', - 'UsedCapacity' + 'UsedCapacity', + 'default' ) .then((results: any) => { expect(results.primaryAggType).toEqual('Total'); @@ -992,8 +992,8 @@ describe('AzureMonitorDatasource', () => { 'nodeapp', 'microsoft.insights/components', 'resource1', - 'default', - 'Transactions' + 'Transactions', + 'default' ) .then((results: any) => { expect(results.dimensions.length).toEqual(4); @@ -1011,8 +1011,8 @@ describe('AzureMonitorDatasource', () => { 'nodeapp', 'microsoft.insights/components', 'resource1', - 'default', - 'FreeCapacity' + 'FreeCapacity', + 'default' ) .then((results: any) => { expect(results.dimensions.length).toEqual(0); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts index 4eb0c2031512..cd7033dc74ac 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -5,9 +5,12 @@ import SupportedNamespaces from './supported_namespaces'; import TimegrainConverter from '../time_grain_converter'; import { AzureMonitorQuery, + AzureMonitorQueryData, AzureDataSourceJsonData, AzureMonitorMetricDefinitionsResponse, AzureMonitorResourceGroupsResponse, + AzureMonitorResourceResponse, + Resource, } from '../types'; import { DataQueryRequest, DataQueryResponseData, DataSourceInstanceSettings } from '@grafana/ui'; @@ -47,60 +50,172 @@ export default class AzureMonitorDatasource { return !!this.subscriptionId && this.subscriptionId.length > 0; } - async query(options: DataQueryRequest): Promise { - const queries = _.filter(options.targets, item => { - return ( - item.hide !== true && - item.azureMonitor.resourceGroup && - item.azureMonitor.resourceGroup !== this.defaultDropdownValue && - item.azureMonitor.resourceName && - item.azureMonitor.resourceName !== this.defaultDropdownValue && - item.azureMonitor.metricDefinition && - item.azureMonitor.metricDefinition !== this.defaultDropdownValue && - item.azureMonitor.metricName && - item.azureMonitor.metricName !== this.defaultDropdownValue - ); - }).map(target => { - const item = target.azureMonitor; + buildQuery( + options: DataQueryRequest, + target: any, + { + resourceGroup, + resourceName, + metricDefinition, + timeGrainUnit, + timeGrain, + metricName, + metricNamespace, + allowedTimeGrainsMs, + aggregation, + dimension, + dimensionFilter, + alias, + }: AzureMonitorQueryData, + subscriptionId?: string + ) { + if (timeGrainUnit && timeGrain !== 'auto') { + timeGrain = TimegrainConverter.createISO8601Duration(timeGrain, timeGrainUnit); + } - // fix for timeGrainUnit which is a deprecated/removed field name - if (item.timeGrainUnit && item.timeGrain !== 'auto') { - item.timeGrain = TimegrainConverter.createISO8601Duration(item.timeGrain, item.timeGrainUnit); - } + const metricNamespaceParsed = this.templateSrv.replace(metricNamespace, options.scopedVars); + + return { + refId: target.refId, + intervalMs: options.intervalMs, + datasourceId: this.id, + subscription: this.templateSrv.replace( + subscriptionId || target.subscription || this.subscriptionId, + options.scopedVars + ), + queryType: 'Azure Monitor', + type: 'timeSeriesQuery', + raw: false, + azureMonitor: { + resourceGroup: this.templateSrv.replace(resourceGroup, options.scopedVars), + resourceName: this.templateSrv.replace(resourceName, options.scopedVars), + metricDefinition: this.templateSrv.replace(metricDefinition, options.scopedVars), + timeGrain: this.templateSrv.replace((timeGrain || '').toString(), options.scopedVars), + allowedTimeGrainsMs: allowedTimeGrainsMs, + metricName: this.templateSrv.replace(metricName, options.scopedVars), + metricNamespace: + metricNamespaceParsed && metricNamespaceParsed !== this.defaultDropdownValue + ? metricNamespaceParsed + : metricDefinition, + aggregation: this.templateSrv.replace(aggregation, options.scopedVars), + dimension: this.templateSrv.replace(dimension, options.scopedVars), + dimensionFilter: this.templateSrv.replace(dimensionFilter, options.scopedVars), + alias, + format: target.format, + }, + }; + } - const subscriptionId = this.templateSrv.replace(target.subscription || this.subscriptionId, options.scopedVars); - const resourceGroup = this.templateSrv.replace(item.resourceGroup, options.scopedVars); - const resourceName = this.templateSrv.replace(item.resourceName, options.scopedVars); - const metricNamespace = this.templateSrv.replace(item.metricNamespace, options.scopedVars); - const metricDefinition = this.templateSrv.replace(item.metricDefinition, options.scopedVars); - const timeGrain = this.templateSrv.replace((item.timeGrain || '').toString(), options.scopedVars); - const aggregation = this.templateSrv.replace(item.aggregation, options.scopedVars); + buildSingleQuery( + options: DataQueryRequest, + target: any, + { + resourceGroup, + resourceName, + metricDefinition, + timeGrainUnit, + timeGrain, + metricName, + metricNamespace, + allowedTimeGrainsMs, + aggregation, + dimension, + dimensionFilter, + alias, + }: AzureMonitorQueryData, + queryMode: string + ) { + if (timeGrainUnit && timeGrain !== 'auto') { + timeGrain = TimegrainConverter.createISO8601Duration(timeGrain, timeGrainUnit); + } - return { - refId: target.refId, - intervalMs: options.intervalMs, - datasourceId: this.id, - subscription: subscriptionId, - queryType: 'Azure Monitor', - type: 'timeSeriesQuery', - raw: false, - azureMonitor: { - resourceGroup: resourceGroup, - resourceName: resourceName, - metricDefinition: metricDefinition, - timeGrain: timeGrain, - allowedTimeGrainsMs: item.allowedTimeGrainsMs, - metricName: this.templateSrv.replace(item.metricName, options.scopedVars), - metricNamespace: - metricNamespace && metricNamespace !== this.defaultDropdownValue ? metricNamespace : metricDefinition, - aggregation: aggregation, - dimension: this.templateSrv.replace(item.dimension, options.scopedVars), - dimensionFilter: this.templateSrv.replace(item.dimensionFilter, options.scopedVars), - alias: item.alias, - format: target.format, + const metricNamespaceParsed = this.templateSrv.replace(metricNamespace, options.scopedVars); + + return { + refId: target.refId, + intervalMs: options.intervalMs, + datasourceId: this.id, + subscription: this.templateSrv.replace(target.subscription || this.subscriptionId, options.scopedVars), + queryType: 'Azure Monitor', + type: 'timeSeriesQuery', + raw: false, + azureMonitor: { + queryMode, + data: { + [queryMode]: { + resourceGroup: this.templateSrv.replace(resourceGroup, options.scopedVars), + resourceName: this.templateSrv.replace(resourceName, options.scopedVars), + metricDefinition: this.templateSrv.replace(metricDefinition, options.scopedVars), + timeGrain: this.templateSrv.replace((timeGrain || '').toString(), options.scopedVars), + allowedTimeGrainsMs: allowedTimeGrainsMs, + metricName: this.templateSrv.replace(metricName, options.scopedVars), + metricNamespace: + metricNamespaceParsed && metricNamespaceParsed !== this.defaultDropdownValue + ? metricNamespaceParsed + : metricDefinition, + aggregation: this.templateSrv.replace(aggregation, options.scopedVars), + dimension: this.templateSrv.replace(dimension, options.scopedVars), + dimensionFilter: this.templateSrv.replace(dimensionFilter, options.scopedVars), + alias, + format: target.format, + }, }, - }; - }); + }, + }; + } + + async query(options: DataQueryRequest): Promise { + const groupedQueries: any[] = await Promise.all( + options.targets + .filter(item => { + const { data, queryMode } = item.azureMonitor; + const { resourceGroup, resourceGroups, metricDefinition, metricName } = data[queryMode]; + + return ( + item.hide !== true && + ((resourceGroup && resourceGroup !== this.defaultDropdownValue) || resourceGroups.length) && + metricDefinition && + metricDefinition !== this.defaultDropdownValue && + metricName && + metricName !== this.defaultDropdownValue + ); + }) + .map(async target => { + const { data, queryMode } = target.azureMonitor; + + if (queryMode === 'crossResource') { + const { resourceGroups, metricDefinition, locations } = data[queryMode]; + const resources = await this.getResources(target.subscriptions).then(resources => + resources.filter( + ({ type, group, subscriptionId, location }) => + target.subscriptions.includes(subscriptionId) && + resourceGroups.includes(group) && + locations.includes(location) && + metricDefinition === type + ) + ); + delete data.crossResource.metricNamespace; + return resources.map( + ({ type: metricDefinition, group: resourceGroup, subscriptionId, name: resourceName }) => + this.buildQuery( + options, + target, + { + ...data[queryMode], + metricDefinition, + resourceGroup, + resourceName, + }, + subscriptionId + ) + ); + } else { + return Promise.resolve(this.buildSingleQuery(options, target, data[queryMode], queryMode)); + } + }) + ); + + const queries = _.flatten(groupedQueries); if (!queries || queries.length === 0) { return Promise.resolve([]); @@ -118,7 +233,7 @@ export default class AzureMonitorDatasource { const result: DataQueryResponseData[] = []; if (data.results) { - Object['values'](data.results).forEach((queryRes: any) => { + Object.values(data.results).forEach((queryRes: any) => { if (!queryRes.series) { return; } @@ -337,12 +452,31 @@ export default class AzureMonitorDatasource { }); } + async getResources(subscriptionIds: string[]): Promise { + const responses: Resource[][] = await Promise.all( + subscriptionIds.map(subscriptionId => + this.doRequest(`${this.baseUrl}/${subscriptionId}/resources?api-version=2018-02-01`).then( + (res: AzureMonitorResourceResponse) => + res.data.value + .map(r => ({ + ...r, + group: /.*\/resourceGroups\/(.*?)\//.exec(r.id)[1], + subscriptionId, + })) + .filter(({ type }) => this.supportedMetricNamespaces.includes(type)) + ) + ) + ); + + return responses.reduce((result, resources) => [...result, ...resources], []); + } + getMetricNames( subscriptionId: string, resourceGroup: string, metricDefinition: string, resourceName: string, - metricNamespace: string + metricNamespace?: string ) { const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( this.baseUrl, @@ -350,8 +484,8 @@ export default class AzureMonitorDatasource { resourceGroup, metricDefinition, resourceName, - metricNamespace, - this.apiVersion + this.apiVersion, + metricNamespace ); return this.doRequest(url).then((result: any) => { @@ -364,8 +498,8 @@ export default class AzureMonitorDatasource { resourceGroup: string, metricDefinition: string, resourceName: string, - metricNamespace: string, - metricName: string + metricName: string, + metricNamespace?: string ) { const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( this.baseUrl, @@ -373,8 +507,8 @@ export default class AzureMonitorDatasource { resourceGroup, metricDefinition, resourceName, - metricNamespace, - this.apiVersion + this.apiVersion, + metricNamespace ); return this.doRequest(url).then((result: any) => { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts index d67b1d74c1b7..e9f951b752f4 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts @@ -108,8 +108,8 @@ export default class ResponseParser { return dimensions; } - static parseSubscriptions(result: any): Array<{ text: string; value: string }> { - const list: Array<{ text: string; value: string }> = []; + static parseSubscriptions(result: any): Array<{ text: string; value: string; displayName: string }> { + const list: Array<{ text: string; value: string; displayName: string }> = []; if (!result) { return list; @@ -122,6 +122,7 @@ export default class ResponseParser { list.push({ text: `${_.get(result.data.value[i], textFieldName)} - ${_.get(result.data.value[i], valueFieldName)}`, value: _.get(result.data.value[i], valueFieldName), + displayName: _.get(result.data.value[i], textFieldName), }); } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts index 0762266dc327..b4ffd1857d1c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts @@ -9,8 +9,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Sql/servers/databases', 'rn1/rn2', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + @@ -27,8 +27,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Sql/servers', 'rn', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + @@ -45,8 +45,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/blobServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' + @@ -63,8 +63,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/fileServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' + @@ -81,8 +81,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/tableServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' + @@ -99,8 +99,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/queueServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + @@ -108,4 +108,22 @@ describe('AzureMonitorUrlBuilder', () => { ); }); }); + + describe('when metric namespace is missing', () => { + it('should be excluded from the query', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/queueServices', + 'rn1/default', + '2017-05-01-preview' + ); + + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview' + ); + }); + }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts index e8cb3afcc1c9..98ffb9d0c400 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts @@ -29,26 +29,24 @@ export default class UrlBuilder { resourceGroup: string, metricDefinition: string, resourceName: string, - metricNamespace: string, - apiVersion: string + apiVersion: string, + metricNamespace?: string ) { + const metricNameSpaceParam = metricNamespace ? `&metricnamespace=${encodeURIComponent(metricNamespace)}` : ''; if ((metricDefinition.match(/\//g) || []).length > 1) { const rn = resourceName.split('/'); const service = metricDefinition.substring(metricDefinition.lastIndexOf('/') + 1); const md = metricDefinition.substring(0, metricDefinition.lastIndexOf('/')); + return ( `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${md}/${rn[0]}/${service}/${rn[1]}` + - `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}&metricnamespace=${encodeURIComponent( - metricNamespace - )}` + `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}${metricNameSpaceParam}` ); } return ( `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${metricDefinition}/${resourceName}` + - `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}&metricnamespace=${encodeURIComponent( - metricNamespace - )}` + `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}${metricNameSpaceParam}` ); } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts index ac579bcb3831..744f90679552 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { migrateTargetSchema } from './migrations'; import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource'; import AppInsightsDatasource from './app_insights/app_insights_datasource'; import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource'; @@ -42,7 +43,9 @@ export default class Datasource extends DataSourceApi t.queryType === 'Azure Monitor') + .map((t: any) => migrateTargetSchema(t)); appInsightsOptions.targets = _.filter(appInsightsOptions.targets, ['queryType', 'Application Insights']); azureLogAnalyticsOptions.targets = _.filter(azureLogAnalyticsOptions.targets, ['queryType', 'Azure Log Analytics']); @@ -163,7 +166,7 @@ export default class Datasource extends DataSourceApi; + selectedValues: Array<{ text: string; value: string }>; + initialValues: string[]; + onUpdated: any; + + show() { + this.highlightIndex = -1; + this.options = this.options; + this.selectedValues = this.options.filter(({ selected }) => selected); + + this.dropdownVisible = true; + } + + hide() { + this.dropdownVisible = false; + } + + updateLinkText() { + this.linkText = + this.selectedValues.length === 1 ? this.selectedValues[0].text : `(${this.selectedValues.length}) selected`; + } + + clearSelections() { + this.selectedValues = _.filter(this.options, { selected: true }); + + if (this.selectedValues.length > 1) { + _.each(this.options, option => { + option.selected = false; + }); + } else { + _.each(this.options, option => { + option.selected = true; + }); + } + this.selectionsChanged(); + } + + selectValue(option: any) { + if (!option) { + return; + } + + option.selected = !option.selected; + this.selectionsChanged(); + } + + selectionsChanged() { + this.selectedValues = _.filter(this.options, { selected: true }); + if (!this.selectedValues.length && this.options.length) { + this.selectedValues = this.options.slice(0, 1); + } + this.updateLinkText(); + this.onUpdated({ values: this.selectedValues.map(({ value }) => value) }); + } + + onClickOutside() { + this.selectedValues = _.filter(this.options, { selected: true }); + if (this.selectedValues.length === 0) { + this.options[0].selected = true; + this.selectionsChanged(); + } + this.dropdownVisible = false; + } + + init() { + if (!this.options) { + return; + } + + this.options = this.options.map(o => ({ + ...o, + selected: this.initialValues.includes(o.value), + })); + this.selectedValues = _.filter(this.options, { selected: true }); + if (!this.selectedValues.length) { + this.options = this.options.map(o => ({ + ...o, + selected: true, + })); + } + this.updateLinkText(); + } + + updateSelection() { + this.selectedValues = _.filter(this.options, { selected: true }); + if (!this.selectedValues.length && this.options.length) { + this.options = this.options.map(o => ({ + ...o, + selected: true, + })); + this.selectedValues = _.filter(this.options, { selected: true }); + this.selectionsChanged(); + } + this.updateLinkText(); + } +} + +/** @ngInject */ +export function multiSelectDropdown($window: any, $timeout: any) { + return { + scope: { onUpdated: '&', options: '=', initialValues: '=' }, + templateUrl: 'public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html', + controller: MultiSelectDropdownCtrl, + controllerAs: 'vm', + bindToController: true, + link: (scope: any, elem: any) => { + const bodyEl = angular.element($window.document.body); + const linkEl = elem.find('.variable-value-link'); + const inputEl = elem.find('input'); + + function openDropdown() { + inputEl.css('width', Math.max(linkEl.width(), 80) + 'px'); + + inputEl.show(); + linkEl.hide(); + + inputEl.focus(); + $timeout( + () => { + bodyEl.on('click', () => { + bodyEl.on('click', bodyOnClick); + }); + }, + 0, + false + ); + } + + function switchToLink() { + inputEl.hide(); + linkEl.show(); + bodyEl.off('click', bodyOnClick); + } + + function bodyOnClick(e: any) { + if (elem.has(e.target).length === 0) { + scope.$apply(() => { + scope.vm.onClickOutside(); + }); + } + } + + scope.$watch('vm.options', (newValue: any) => { + if (newValue) { + scope.vm.updateSelection(newValue); + } + }); + + scope.$watch('vm.dropdownVisible', (newValue: any) => { + if (newValue) { + openDropdown(); + } else { + switchToLink(); + } + }); + + scope.vm.init(); + }, + }; +} + +angular.module('grafana.directives').directive('multiSelect', multiSelectDropdown); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html new file mode 100644 index 000000000000..b48afe1ad361 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html index a5bed66c9df8..5b2d1010ca97 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html @@ -7,70 +7,138 @@ ng-change="ctrl.onQueryTypeChange()">
-
+
+ +
+ +
+
+
+
+
+
+
+
+ get-options="ctrl.getSubscriptions()" on-change="ctrl.onSubscriptionChange()" css-class="min-width-6">
-
-
-
- - - -
-
- - - -
-
- - - -
-
-
-
+
+
+ + +
+
+
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
-
+
-
- + +
-
+
-
+
+
+
-
-
+
@@ -78,17 +146,17 @@
-
+
-
-
@@ -98,7 +166,7 @@
-
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts index 9c83b5d9312d..4d440fd9a14c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts @@ -36,11 +36,11 @@ describe('AzureMonitorQueryCtrl', () => { }); it('should set query parts to select', () => { - expect(queryCtrl.target.azureMonitor.resourceGroup).toBe('select'); - expect(queryCtrl.target.azureMonitor.metricDefinition).toBe('select'); - expect(queryCtrl.target.azureMonitor.resourceName).toBe('select'); - expect(queryCtrl.target.azureMonitor.metricNamespace).toBe('select'); - expect(queryCtrl.target.azureMonitor.metricName).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.resourceGroup).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.metricDefinition).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.resourceName).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.metricNamespace).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.metricName).toBe('select'); expect(queryCtrl.target.appInsights.groupBy).toBe('none'); }); }); @@ -76,7 +76,7 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; queryCtrl.datasource.getMetricDefinitions = function(subscriptionId: any, query: any) { expect(subscriptionId).toBe('sub1'); expect(query).toBe('test'); @@ -94,7 +94,7 @@ describe('AzureMonitorQueryCtrl', () => { describe('and resource group has no value', () => { beforeEach(() => { - queryCtrl.target.azureMonitor.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'select'; }); it('should return without making a call to datasource', () => { @@ -109,8 +109,8 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; - queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'Microsoft.Compute/virtualMachines'; queryCtrl.datasource.getResourceNames = function( subscriptionId: any, resourceGroup: any, @@ -133,8 +133,8 @@ describe('AzureMonitorQueryCtrl', () => { describe('and resourceGroup and metricDefinition do not have values', () => { beforeEach(() => { - queryCtrl.target.azureMonitor.resourceGroup = 'select'; - queryCtrl.target.azureMonitor.metricDefinition = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'select'; }); it('should return without making a call to datasource', () => { @@ -149,10 +149,10 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; - queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; - queryCtrl.target.azureMonitor.resourceName = 'test'; - queryCtrl.target.azureMonitor.metricNamespace = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.data.singleResource.resourceName = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricNamespace = 'test'; queryCtrl.datasource.getMetricNames = function( subscriptionId: any, resourceGroup: any, @@ -179,10 +179,10 @@ describe('AzureMonitorQueryCtrl', () => { describe('and resourceGroup, metricDefinition, resourceName and metricNamespace do not have values', () => { beforeEach(() => { - queryCtrl.target.azureMonitor.resourceGroup = 'select'; - queryCtrl.target.azureMonitor.metricDefinition = 'select'; - queryCtrl.target.azureMonitor.resourceName = 'select'; - queryCtrl.target.azureMonitor.metricNamespace = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceName = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.metricNamespace = 'select'; }); it('should return without making a call to datasource', () => { @@ -201,18 +201,18 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; - queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; - queryCtrl.target.azureMonitor.resourceName = 'test'; - queryCtrl.target.azureMonitor.metricNamespace = 'test'; - queryCtrl.target.azureMonitor.metricName = 'Percentage CPU'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.data.singleResource.resourceName = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricNamespace = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricName = 'Percentage CPU'; queryCtrl.datasource.getMetricMetadata = function( subscription: any, resourceGroup: any, metricDefinition: any, resourceName: any, - metricNamespace: any, - metricName: any + metricName: any, + metricNamespace: any ) { expect(subscription).toBe('sub1'); expect(resourceGroup).toBe('test'); @@ -226,9 +226,9 @@ describe('AzureMonitorQueryCtrl', () => { it('should set the options and default selected value for the Aggregations dropdown', () => { queryCtrl.onMetricNameChange().then(() => { - expect(queryCtrl.target.azureMonitor.aggregation).toBe('Average'); - expect(queryCtrl.target.azureMonitor.aggOptions).toBe(['Average', 'Total']); - expect(queryCtrl.target.azureMonitor.timeGrains).toBe(['PT1M', 'P1D']); + expect(queryCtrl.target.azureMonitor.data.singleResource.aggregation).toBe('Average'); + expect(queryCtrl.target.azureMonitor.data.singleResource.aggOptions).toBe(['Average', 'Total']); + expect(queryCtrl.target.azureMonitor.data.singleResource.timeGrains).toBe(['PT1M', 'P1D']); }); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts index 0e799d275186..1c5fe02a27e7 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts @@ -1,21 +1,51 @@ import _ from 'lodash'; import { QueryCtrl } from 'app/plugins/sdk'; -// import './css/query_editor.css'; -import TimegrainConverter from './time_grain_converter'; -import './editor/editor_component'; import kbn from 'app/core/utils/kbn'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { auto } from 'angular'; import { DataFrame } from '@grafana/data'; +import { Resource } from './types'; +import { migrateTargetSchema } from './migrations'; +import TimegrainConverter from './time_grain_converter'; +import './editor/editor_component'; +import './multi-select.directive'; + export interface ResultFormat { text: string; value: string; } +interface AzureMonitor { + resourceGroup: string; + resourceGroups: string[]; + resourceName: string; + metricDefinition: string; + metricNamespace: string; + metricName: string; + dimensionFilter: string; + timeGrain: string; + timeGrainUnit: string; + timeGrains: Option[]; + allowedTimeGrainsMs: number[]; + dimensions: any[]; + dimension: any; + aggregation: string; + aggOptions: string[]; + locations: string[]; + queryMode: string; +} + +interface Option { + value: string; + text: string; + displayName?: string; +} + export class AzureMonitorQueryCtrl extends QueryCtrl { static templateUrl = 'partials/query.editor.html'; + static defaultQueryMode = 'singleResource'; defaultDropdownValue = 'select'; @@ -23,21 +53,10 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { refId: string; queryType: string; subscription: string; + subscriptions: string[]; azureMonitor: { - resourceGroup: string; - resourceName: string; - metricDefinition: string; - metricNamespace: string; - metricName: string; - dimensionFilter: string; - timeGrain: string; - timeGrainUnit: string; - timeGrains: Array<{ text: string; value: string }>; - allowedTimeGrainsMs: number[]; - dimensions: any[]; - dimension: any; - aggregation: string; - aggOptions: string[]; + queryMode: string; + data: { [queryMode: string]: AzureMonitor }; }; azureLogAnalytics: { query: string; @@ -63,14 +82,30 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { defaults = { queryType: 'Azure Monitor', + subscriptions: new Array(), azureMonitor: { - resourceGroup: this.defaultDropdownValue, - metricDefinition: this.defaultDropdownValue, - resourceName: this.defaultDropdownValue, - metricNamespace: this.defaultDropdownValue, - metricName: this.defaultDropdownValue, - dimensionFilter: '*', - timeGrain: 'auto', + queryMode: 'singleResource', + data: { + singleResource: { + resourceGroups: new Array(), + resourceGroup: this.defaultDropdownValue, + metricDefinition: this.defaultDropdownValue, + metricNamespace: this.defaultDropdownValue, + metricName: this.defaultDropdownValue, + resourceName: this.defaultDropdownValue, + dimensionFilter: '*', + timeGrain: 'auto', + }, + crossResource: { + resourceGroups: new Array(), + locations: new Array(), + metricDefinition: this.defaultDropdownValue, + resourceName: this.defaultDropdownValue, + metricName: this.defaultDropdownValue, + dimensionFilter: '*', + timeGrain: 'auto', + }, + }, }, azureLogAnalytics: { query: [ @@ -108,12 +143,17 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { showLastQuery: boolean; lastQuery: string; lastQueryError?: string; - subscriptions: Array<{ text: string; value: string }>; + subscriptions: Option[]; + subscriptionValues: string[]; + resources: Resource[]; + locations: Option[]; + resourceGroups: Option[]; /** @ngInject */ constructor($scope: any, $injector: auto.IInjectorService, private templateSrv: TemplateSrv) { super($scope, $injector); + this.target = migrateTargetSchema(this.target); _.defaultsDeep(this.target, this.defaults); this.migrateTimeGrains(); @@ -125,12 +165,35 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope); this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope); this.resultFormats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }]; - this.getSubscriptions(); + this.resources = new Array(); + this.subscriptionValues = []; + + this.init(); if (this.target.queryType === 'Azure Log Analytics') { this.getWorkspaces(); } } + async init() { + const subscriptions = await this.getSubscriptions(); + this.datasource.getResources(subscriptions.map((s: Option) => s.value)).then(async (resources: Resource[]) => { + if (!this.target.subscriptions.length) { + this.target.subscriptions = this.subscriptions.map(s => s.value); + } + this.resources = resources; + this.updateLocations(); + this.updateCrossResourceGroups(); + }); + } + + updateLocations() { + this.locations = this.getLocations().map(l => ({ text: l, value: l })); + } + + updateCrossResourceGroups() { + this.resourceGroups = this.getCrossResourceGroups().map(rg => ({ text: rg, value: rg })); + } + onDataReceived(dataList: DataFrame[]) { this.lastQueryError = undefined; this.lastQuery = ''; @@ -170,24 +233,28 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } migrateTimeGrains() { - if (this.target.azureMonitor.timeGrainUnit) { - if (this.target.azureMonitor.timeGrain !== 'auto') { - this.target.azureMonitor.timeGrain = TimegrainConverter.createISO8601Duration( - this.target.azureMonitor.timeGrain, - this.target.azureMonitor.timeGrainUnit + const { queryMode } = this.target.azureMonitor; + if (this.target.azureMonitor.data[queryMode].timeGrainUnit) { + if (this.target.azureMonitor.data[queryMode].timeGrain !== 'auto') { + this.target.azureMonitor.data[queryMode].timeGrain = TimegrainConverter.createISO8601Duration( + this.target.azureMonitor.data[queryMode].timeGrain, + this.target.azureMonitor.data[queryMode].timeGrainUnit ); } - delete this.target.azureMonitor.timeGrainUnit; + delete this.target.azureMonitor.data[queryMode].timeGrainUnit; this.onMetricNameChange(); } if ( - this.target.azureMonitor.timeGrains && - this.target.azureMonitor.timeGrains.length > 0 && - (!this.target.azureMonitor.allowedTimeGrainsMs || this.target.azureMonitor.allowedTimeGrainsMs.length === 0) + this.target.azureMonitor.data[queryMode].timeGrains && + this.target.azureMonitor.data[queryMode].timeGrains.length > 0 && + (!this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs || + this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs.length === 0) ) { - this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(this.target.azureMonitor.timeGrains); + this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs = this.convertTimeGrainsToMs( + this.target.azureMonitor.data[queryMode].timeGrains + ); } } @@ -197,15 +264,18 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } async migrateToDefaultNamespace() { + const { queryMode } = this.target.azureMonitor; if ( - this.target.azureMonitor.metricNamespace && - this.target.azureMonitor.metricNamespace !== this.defaultDropdownValue && - this.target.azureMonitor.metricDefinition + this.target.azureMonitor.data[queryMode].metricNamespace && + this.target.azureMonitor.data[queryMode].metricNamespace !== this.defaultDropdownValue && + this.target.azureMonitor.data[queryMode].metricDefinition ) { return; } - this.target.azureMonitor.metricNamespace = this.target.azureMonitor.metricDefinition; + this.target.azureMonitor.data[queryMode].metricNamespace = this.target.azureMonitor.data[ + queryMode + ].metricDefinition; } replace(variable: string) { @@ -218,13 +288,19 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } } - getSubscriptions() { + async getSubscriptions() { if (!this.datasource.azureMonitorDatasource.isConfigured()) { return; } return this.datasource.azureMonitorDatasource.getSubscriptions().then((subs: any) => { this.subscriptions = subs; + this.subscriptionValues = subs.map((s: Option) => ({ value: s.value, text: s.displayName })); + + if (!this.target.subscriptions.length) { + this.target.subscriptions = subs.map((s: Option) => s.value); + } + if (!this.target.subscription && this.target.queryType === 'Azure Monitor') { this.target.subscription = this.datasource.azureMonitorDatasource.subscriptionId; } else if (!this.target.subscription && this.target.queryType === 'Azure Log Analytics') { @@ -244,16 +320,36 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.getWorkspaces(); } + const { queryMode } = this.target.azureMonitor; if (this.target.queryType === 'Azure Monitor') { - this.target.azureMonitor.resourceGroup = this.defaultDropdownValue; - this.target.azureMonitor.metricDefinition = this.defaultDropdownValue; - this.target.azureMonitor.resourceName = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + this.target.azureMonitor.data[queryMode].resourceGroup = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + } + } + + async onSubscriptionsChange(values: any) { + if (!_.isEqual(this.target.subscriptions.sort(), values.sort())) { + this.target.subscriptions = values; + this.resources = await this.datasource.getResources(this.target.subscriptions); + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].resourceGroup = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + this.updateLocations(); + this.updateCrossResourceGroups(); } } @@ -270,29 +366,70 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { .catch(this.handleQueryCtrlError.bind(this)); } + getCrossResourceGroups() { + if (this.target.queryType !== 'Azure Monitor' || !this.datasource.azureMonitorDatasource.isConfigured()) { + return []; + } + + return this.resources + .filter(({ location, subscriptionId }) => { + if (this.target.azureMonitor.data.crossResource.locations.length) { + return ( + this.target.azureMonitor.data.crossResource.locations.includes(location) && + this.target.subscriptions.includes(subscriptionId) + ); + } + return this.target.subscriptions.includes(subscriptionId); + }) + .reduce((options, { group }: Resource) => (options.some(o => o === group) ? options : [...options, group]), []); + } + + async getCrossResourceMetricDefinitions(query: any) { + const { locations, resourceGroups } = this.target.azureMonitor.data.crossResource; + return this.resources + .filter(({ location, group }) => locations.includes(location) && resourceGroups.includes(group)) + .reduce( + (options: Option[], { type }: Resource) => + options.some(o => o.value === type) ? options : [...options, { text: type, value: type }], + [] + ); + } + + getLocations() { + return this.resources + .filter(({ subscriptionId }) => this.target.subscriptions.includes(subscriptionId)) + .reduce( + (options: string[], { location }: Resource) => + options.some(o => o === location) ? options : [...options, location], + [] + ); + } + getMetricDefinitions(query: any) { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue ) { return; } return this.datasource .getMetricDefinitions( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup) ) .catch(this.handleQueryCtrlError.bind(this)); } getResourceNames(query: any) { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || - !this.target.azureMonitor.metricDefinition || - this.target.azureMonitor.metricDefinition === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricDefinition || + this.target.azureMonitor.data[queryMode].metricDefinition === this.defaultDropdownValue ) { return; } @@ -300,21 +437,22 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.datasource .getResourceNames( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition) ) .catch(this.handleQueryCtrlError.bind(this)); } getMetricNamespaces() { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || - !this.target.azureMonitor.metricDefinition || - this.target.azureMonitor.metricDefinition === this.defaultDropdownValue || - !this.target.azureMonitor.resourceName || - this.target.azureMonitor.resourceName === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricDefinition || + this.target.azureMonitor.data[queryMode].metricDefinition === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].resourceName || + this.target.azureMonitor.data[queryMode].resourceName === this.defaultDropdownValue ) { return; } @@ -322,24 +460,50 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.datasource .getMetricNamespaces( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition), - this.replace(this.target.azureMonitor.resourceName) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition), + this.replace(this.target.azureMonitor.data[queryMode].resourceName) ) .catch(this.handleQueryCtrlError.bind(this)); } + async getCrossResourceMetricNames() { + const { locations, resourceGroups, metricDefinition } = this.target.azureMonitor.data.crossResource; + + const resources = this.resources.filter( + ({ type, location, name, group }) => + resourceGroups.includes(group) && type === metricDefinition && locations.includes(location) + ); + + const uniqueResources = _.uniqBy(resources, ({ subscriptionId, name, type, group }: Resource) => + [subscriptionId, name, locations, group].join() + ); + + const responses = await Promise.all( + uniqueResources.map(({ subscriptionId, group, type, name }) => + this.datasource + .getMetricNames(subscriptionId, group, type, name) + .then((metrics: any) => metrics.map((m: any) => ({ ...m, subscriptionIds: [subscriptionId] })), [ + { text: this.defaultDropdownValue, value: this.defaultDropdownValue }, + ]) + ) + ); + + return _.uniqBy(responses.reduce((result, resources) => [...result, ...resources], []), ({ value }) => value); + } + getMetricNames() { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || - !this.target.azureMonitor.metricDefinition || - this.target.azureMonitor.metricDefinition === this.defaultDropdownValue || - !this.target.azureMonitor.resourceName || - this.target.azureMonitor.resourceName === this.defaultDropdownValue || - !this.target.azureMonitor.metricNamespace || - this.target.azureMonitor.metricNamespace === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricDefinition || + this.target.azureMonitor.data[queryMode].metricDefinition === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].resourceName || + this.target.azureMonitor.data[queryMode].resourceName === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricNamespace || + this.target.azureMonitor.data[queryMode].metricNamespace === this.defaultDropdownValue ) { return; } @@ -347,87 +511,168 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.datasource .getMetricNames( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition), - this.replace(this.target.azureMonitor.resourceName), - this.replace(this.target.azureMonitor.metricNamespace) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition), + this.replace(this.target.azureMonitor.data[queryMode].resourceName), + this.replace(this.target.azureMonitor.data[queryMode].metricNamespace) ) .catch(this.handleQueryCtrlError.bind(this)); } onResourceGroupChange() { - this.target.azureMonitor.metricDefinition = this.defaultDropdownValue; - this.target.azureMonitor.resourceName = this.defaultDropdownValue; - this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; this.refresh(); } + onCrossResourceGroupChange(values: string[]) { + if (!_.isEqual(this.target.azureMonitor.data.crossResource.resourceGroups.sort(), values.sort())) { + this.target.azureMonitor.data.crossResource.resourceGroups = values; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricDefinition = ''; + this.target.azureMonitor.data[queryMode].metricName = ''; + this.refresh(); + } + } + + onCrossResourceMetricDefinitionChange() { + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + this.refresh(); + } + + async onLocationsChange(values: string[]) { + if (!_.isEqual(this.target.azureMonitor.data.crossResource.locations.sort(), values.sort())) { + this.target.azureMonitor.data.crossResource.locations = values; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricDefinition = ''; + this.target.azureMonitor.data[queryMode].resourceGroup = ''; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + this.updateCrossResourceGroups(); + this.refresh(); + } + } + onMetricDefinitionChange() { - this.target.azureMonitor.resourceName = this.defaultDropdownValue; - this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; } onResourceNameChange() { - this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; this.refresh(); } onMetricNamespacesChange() { - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + } + + setMetricMetadata(metadata: any) { + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType]; + this.target.azureMonitor.data[queryMode].aggregation = metadata.primaryAggType; + this.target.azureMonitor.data[queryMode].timeGrains = [{ text: 'auto', value: 'auto' }].concat( + metadata.supportedTimeGrains + ); + this.target.azureMonitor.data[queryMode].timeGrain = 'auto'; + + this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs = this.convertTimeGrainsToMs( + metadata.supportedTimeGrains || [] + ); + + this.target.azureMonitor.data[queryMode].dimensions = metadata.dimensions; + if (metadata.dimensions.length > 0) { + this.target.azureMonitor.data[queryMode].dimension = metadata.dimensions[0].value; + } + return this.refresh(); } - onMetricNameChange() { - if (!this.target.azureMonitor.metricName || this.target.azureMonitor.metricName === this.defaultDropdownValue) { + onCrossResourceMetricNameChange() { + const { queryMode } = this.target.azureMonitor; + if ( + !this.target.azureMonitor.data[queryMode].metricName || + this.target.azureMonitor.data[queryMode].metricName === this.defaultDropdownValue + ) { return; } + const { resourceGroups, metricDefinition, metricName } = this.target.azureMonitor.data[queryMode]; + + const resource = this.resources.find( + ({ type, group }) => type === metricDefinition && resourceGroups.includes(group) + ); + return this.datasource .getMetricMetadata( - this.replace(this.target.subscription), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition), - this.replace(this.target.azureMonitor.resourceName), - this.replace(this.target.azureMonitor.metricNamespace), - this.replace(this.target.azureMonitor.metricName) + this.replace(this.target.subscriptions[0]), + resource.group, + metricDefinition, + resource.name, + metricName ) - .then((metadata: any) => { - this.target.azureMonitor.aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType]; - this.target.azureMonitor.aggregation = metadata.primaryAggType; - this.target.azureMonitor.timeGrains = [{ text: 'auto', value: 'auto' }].concat(metadata.supportedTimeGrains); - this.target.azureMonitor.timeGrain = 'auto'; + .then(this.setMetricMetadata.bind(this)) + .then(() => this.refresh()) + .catch(this.handleQueryCtrlError.bind(this)); + } - this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(metadata.supportedTimeGrains || []); + onMetricNameChange() { + const { queryMode } = this.target.azureMonitor; + if ( + !this.target.azureMonitor.data[queryMode].metricName || + this.target.azureMonitor.data[queryMode].metricName === this.defaultDropdownValue + ) { + return; + } - this.target.azureMonitor.dimensions = metadata.dimensions; - if (metadata.dimensions.length > 0) { - this.target.azureMonitor.dimension = metadata.dimensions[0].value; - } - return this.refresh(); - }) + return this.datasource + .getMetricMetadata( + this.replace(this.target.subscription), + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition), + this.replace(this.target.azureMonitor.data[queryMode].resourceName), + this.replace(this.target.azureMonitor.data[queryMode].metricName), + this.replace(this.target.azureMonitor.data[queryMode].metricNamespace) + ) + .then(this.setMetricMetadata.bind(this)) .catch(this.handleQueryCtrlError.bind(this)); } - convertTimeGrainsToMs(timeGrains: Array<{ text: string; value: string }>) { + convertTimeGrainsToMs(timeGrains: Option[]) { const allowedTimeGrainsMs: number[] = []; timeGrains.forEach((tg: any) => { if (tg.value !== 'auto') { @@ -438,10 +683,11 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } getAutoInterval() { - if (this.target.azureMonitor.timeGrain === 'auto') { + const { queryMode } = this.target.azureMonitor; + if (this.target.azureMonitor.data[queryMode].timeGrain === 'auto') { return TimegrainConverter.findClosestTimeGrain( this.templateSrv.getBuiltInIntervalValue(), - _.map(this.target.azureMonitor.timeGrains, o => + _.map(this.target.azureMonitor.data[queryMode].timeGrains, o => TimegrainConverter.createKbnUnitFromISO8601Duration(o.value) ) || ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'] ); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts index 7be4bad2c86d..7fe013419696 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts @@ -3,6 +3,7 @@ import { DataQuery, DataSourceJsonData } from '@grafana/ui'; export interface AzureMonitorQuery extends DataQuery { format: string; subscription: string; + subscriptions: string[]; azureMonitor: AzureMetricQuery; azureLogAnalytics: AzureLogsQuery; // appInsights: any; @@ -26,9 +27,9 @@ export interface AzureDataSourceJsonData extends DataSourceJsonData { // App Insights appInsightsAppId?: string; } - -export interface AzureMetricQuery { +export interface AzureMonitorQueryData { resourceGroup: string; + resourceGroups: string[]; resourceName: string; metricDefinition: string; metricNamespace: string; @@ -41,6 +42,12 @@ export interface AzureMetricQuery { dimension: string; dimensionFilter: string; alias: string; + locations: string[]; +} + +export interface AzureMetricQuery extends AzureMonitorQueryData { + queryMode: string; + data: { [queryMode: string]: AzureMonitorQueryData }; } export interface AzureLogsQuery { @@ -67,6 +74,24 @@ export interface AzureMonitorResourceGroupsResponse { statusText: string; } +export interface Resource { + id: string; + name: string; + type: string; + location: string; + kind: string; + subscriptionId: string; + group: string; +} + +export interface AzureMonitorResourceResponse { + data: { + value: Resource[]; + status: number; + statusText: string; + }; +} + // Azure Log Analytics types export interface KustoSchema { Databases: { [key: string]: KustoDatabase }; From bc0f909a96065d51b8a3f90e89ecdc5f92dae4c1 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 12:11:27 +0200 Subject: [PATCH 76/87] Docs: Add docs around feature toggles config (#19162) --- docs/sources/installation/configuration.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index f4a2a61a468d..2920cd2c235b 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -702,6 +702,11 @@ is false. This settings was introduced in Grafana v6.0. Set to true if you want to test alpha plugins that are not yet ready for general usage. +## [feature_toggles] +### enable + +Keys of alpha features to enable, separated by space. Available alpha features are: `transformations` +
# Removed options From b25cbef361e1efd7d59f45c4cedc224b304f19af Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 12:15:04 +0200 Subject: [PATCH 77/87] Docs: What's new 6.4 draft (#19144) * What's new 6.4 draft * data model notes * minor progress * Only include top highlights list --- docs/sources/features/panels/graph.md | 4 ++- docs/sources/guides/whats-new-in-v6-4.md | 39 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/sources/guides/whats-new-in-v6-4.md diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 33a51274454d..37eac3aec81f 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -210,6 +210,9 @@ available suggestions: #### Built-in variables +> These variables changed in 6.4 so if you have an older version of Grafana please use the version picker to select +docs for an older version of Grafana. + ``__url_time_range`` - current dashboard's time range (i.e. ``?from=now-6h&to=now``) ``__from`` - current dashboard's time range from value ``__to`` - current dashboard's time range to value @@ -240,7 +243,6 @@ Value specific variables are available under ``__value`` namespace: ``__value.calc`` - calculation name if the value is result of calculation - #### Template variables When linking to another dashboard that uses template variables, you can use ``var-myvar=${myvar}`` syntax (where ``myvar`` is a name of template variable) diff --git a/docs/sources/guides/whats-new-in-v6-4.md b/docs/sources/guides/whats-new-in-v6-4.md new file mode 100644 index 000000000000..2c7eab68741d --- /dev/null +++ b/docs/sources/guides/whats-new-in-v6-4.md @@ -0,0 +1,39 @@ ++++ +title = "What's New in Grafana v6.4" +description = "Feature & improvement highlights for Grafana v6.4" +keywords = ["grafana", "new", "documentation", "6.4"] +type = "docs" +[menu.docs] +name = "Version 6.4" +identifier = "v6.4" +parent = "whatsnew" +weight = -15 ++++ + +# What's New in Grafana v6.4 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +## Highlights + +Grafana 6.4 comes with a lot of new features and enhancements backed with tons of work around the data models and query +execution that is going to enable powerful future capabilities. Some of those new capabilities can already be seen in +this release, like sharing query results between panels. + +- [**Explore:** Go back to dashboard (with query changes)]({{< relref "#go-back-to-dashboard-from-explore" >}}) +- [**Explore:** Live tailing improvements]({{< relref "#live-tailing-improvements" >}}) +- [**Loki:** Show logs as annotations in dashboard graphs]({{< relref "#loki-annotations" >}}) +- [**Loki:** Use Loki in dashboard panels]({{< relref "#loki-in-dashbaords" >}}) +- [**Panels:** New logs panel]({{< relref "#new-logs-panel" >}}) +- [**Panels:** Data links improvements]({{< relref "#data-links-improvements" >}}) +- [**Dashboard:** Share query results between panels]({{< relref "#share-query-results" >}}) +- [**Plugins:** Alpha version of grafana-toolkit]({{< relref "#alpha-version-of-grafana-toolkit" >}}) +- [**ImageRendering:** PhantomJS deprecation]({{< relref "#phantomjs-deprecation" >}}) +- [**Docker:** Alpine based docker image]({{< relref "#alpine-based-docker-image" >}}) +- [**LDAP Debug View**: Reporting]({{< relref "#ldap-debug-view" >}}) +- [**Enterprise**: Reporting]({{< relref "#reporting" >}}) +- [**Enterprise**: GitLab OAuth Team Sync support]({{< relref "#gitlab-oauth-team-sync-support" >}}) +- [**Enterprise**: Teams & LDAP Improvements]({{< relref "#ldap-teams" >}}) + + +More details on the above coming soon! From e3a69d8023c5fc3c0313650d0e0151e7b043749e Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 17 Sep 2019 13:09:29 +0200 Subject: [PATCH 78/87] Changelog: Breaking changes and deprecation notes for v6.4 (#19164) Breaking changes and deprecation notes for v6.4 Closes #18520 Co-Authored-By: Dominik Prokop --- CHANGELOG.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e93f3b834210..1b0f51776e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,33 @@ # 6.4.0 (unreleased) -### Breaking changes +## Breaking changes + +### Annotations + +There are some breaking changes in the annotations HTTP API for region annotations. Region annotations are now represented +using a single event instead of two seperate events. Check breaking changes in HTTP API [below](#http-api) and [HTTP API documentation](https://grafana.com/docs/http_api/annotations/) for more details. + +### Docker + +Grafana is now using Alpine 3.10 as docker base image. + +### HTTP API + - `GET /api/alert-notifications` now requires at least editor access. New `/api/alert-notifications/lookup` returns less information than `/api/alert-notifications` and can be access by any authenticated user. + - `GET /api/alert-notifiers` now requires at least editor access + - `GET /api/org/users` now requires org admin role. New `/api/org/users/lookup` returns less information than `/api/org/users` and can be access by users that are org admins, admin in any folder or admin of any team. + - `GET /api/annotations` no longer returns `regionId` property. + - `POST /api/annotations` no longer supports `isRegion` property. + - `PUT /api/annotations/:id` no longer supports `isRegion` property. + - `PATCH /api/annotations/:id` no longer supports `isRegion` property. + - `DELETE /api/annotations/region/:id` has been removed. + +## Deprecation notes + +### PhantomJS + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, is deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. -* **Annotations**: There are some breaking changes in the annotations HTTP API for region annotations. Region - annotations are now represented using a single event instead of two seperate events. Check HTTP docs for more details. +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). # 6.3.5 (2019-09-02) From 601853fc8453f73fb5df4bd0fe8c2a228aed2d3d Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue, 17 Sep 2019 12:16:24 +0100 Subject: [PATCH 79/87] Chore: Update Slate to 0.47.8 (#18412) * Chore: Update Slate to 0.47.8 Closes #17430 * Add slate and immutable to grafana-ui deps * Fixes some small regressions introduced * Fix suggestions for multiple query fields * Pin upgraded dependencies * Prettier fix * Remove original slate-react dependency * Fix tiny-invariant dep * (Temporarily) comments out source maps for grafana-ui --- package.json | 11 +- .../src/config/webpack.plugin.config.ts | 2 +- packages/grafana-ui/package.json | 5 + packages/grafana-ui/rollup.config.ts | 7 +- .../components/DataLinks/DataLinkInput.tsx | 46 +- packages/grafana-ui/src/index.ts | 1 + .../grafana-ui/src/slate-plugins/index.ts | 1 + .../slate-plugins/slate-prism/TOKEN_MARK.ts | 3 + .../src/slate-plugins/slate-prism/index.ts | 160 +++++ .../src/slate-plugins/slate-prism/options.tsx | 77 +++ packages/grafana-ui/src/utils/slate.ts | 27 +- packages/grafana-ui/tsconfig.json | 4 + .../app/features/explore/QueryField.test.tsx | 41 -- public/app/features/explore/QueryField.tsx | 607 ++---------------- public/app/features/explore/Typeahead.tsx | 224 ++++--- public/app/features/explore/TypeaheadInfo.tsx | 25 +- public/app/features/explore/TypeaheadItem.tsx | 36 +- .../explore/slate-plugins/braces.test.ts | 39 -- .../explore/slate-plugins/braces.test.tsx | 40 ++ .../features/explore/slate-plugins/braces.ts | 40 +- .../explore/slate-plugins/clear.test.ts | 39 -- .../explore/slate-plugins/clear.test.tsx | 42 ++ .../features/explore/slate-plugins/clear.ts | 21 +- .../explore/slate-plugins/clipboard.ts | 61 ++ .../explore/slate-plugins/indentation.ts | 93 +++ .../features/explore/slate-plugins/newline.ts | 21 +- .../explore/slate-plugins/runner.test.tsx | 17 + .../features/explore/slate-plugins/runner.ts | 7 +- .../slate-plugins/selection_shortcuts.ts | 72 +++ .../explore/slate-plugins/suggestions.tsx | 313 +++++++++ .../app/features/explore/utils/typeahead.ts | 8 +- public/app/features/plugins/plugin_loader.ts | 4 +- .../components/ElasticsearchQueryField.tsx | 8 +- .../editor/KustoQueryField.tsx | 17 +- .../editor/query_field.tsx | 59 +- .../loki/components/LokiQueryField.tsx | 3 +- .../loki/components/LokiQueryFieldForm.tsx | 49 +- .../loki/components/useLokiSyntax.test.ts | 1 + .../loki/components/useLokiSyntax.ts | 1 - .../datasource/loki/language_provider.test.ts | 57 +- .../datasource/loki/language_provider.ts | 54 +- public/app/plugins/datasource/loki/mocks.ts | 4 +- public/app/plugins/datasource/loki/syntax.ts | 4 +- .../prometheus/components/PromQueryField.tsx | 34 +- .../prometheus/language_provider.ts | 145 +++-- .../datasource/prometheus/language_utils.ts | 8 +- .../specs/language_provider.test.ts | 222 +++---- public/app/types/explore.ts | 32 +- public/sass/components/_slate_editor.scss | 8 +- tsconfig.json | 3 +- yarn.lock | 220 +++---- 51 files changed, 1705 insertions(+), 1318 deletions(-) create mode 100644 packages/grafana-ui/src/slate-plugins/index.ts create mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/TOKEN_MARK.ts create mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/index.ts create mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx delete mode 100644 public/app/features/explore/slate-plugins/braces.test.ts create mode 100644 public/app/features/explore/slate-plugins/braces.test.tsx delete mode 100644 public/app/features/explore/slate-plugins/clear.test.ts create mode 100644 public/app/features/explore/slate-plugins/clear.test.tsx create mode 100644 public/app/features/explore/slate-plugins/clipboard.ts create mode 100644 public/app/features/explore/slate-plugins/indentation.ts create mode 100644 public/app/features/explore/slate-plugins/runner.test.tsx create mode 100644 public/app/features/explore/slate-plugins/selection_shortcuts.ts create mode 100644 public/app/features/explore/slate-plugins/suggestions.tsx diff --git a/package.json b/package.json index a53ab845d1ea..0f9a5d882f13 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "@types/redux-logger": "3.0.7", "@types/redux-mock-store": "1.0.1", "@types/reselect": "2.2.0", - "@types/slate": "0.44.11", + "@types/slate": "0.47.1", + "@types/slate-plain-serializer": "0.6.1", + "@types/slate-react": "0.22.5", "@types/tinycolor2": "1.4.2", "angular-mocks": "1.6.6", "autoprefixer": "9.5.0", @@ -193,6 +195,7 @@ }, "dependencies": { "@babel/polyfill": "7.2.5", + "@grafana/slate-react": "0.22.9-grafana", "@torkelo/react-select": "2.4.1", "angular": "1.6.6", "angular-bindonce": "0.3.1", @@ -243,10 +246,8 @@ "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "6.4.0", "search-query-parser": "1.5.2", - "slate": "0.33.8", - "slate-plain-serializer": "0.5.41", - "slate-prism": "0.5.0", - "slate-react": "0.12.11", + "slate": "0.47.8", + "slate-plain-serializer": "0.7.10", "tether": "1.4.5", "tether-drop": "https://github.com/torkelo/drop/tarball/master", "tinycolor2": "1.4.1", diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 07b9f350cb82..34c663c4fe83 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -149,7 +149,7 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { 'emotion', 'prismjs', 'slate-plain-serializer', - 'slate-react', + '@grafana/slate-react', 'react', 'react-dom', 'react-redux', diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 6fdc543fdb48..ac98227b8edb 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -26,10 +26,12 @@ }, "dependencies": { "@grafana/data": "^6.4.0-alpha", + "@grafana/slate-react": "0.22.9-grafana", "@torkelo/react-select": "2.1.1", "@types/react-color": "2.17.0", "classnames": "2.2.6", "d3": "5.9.1", + "immutable": "3.8.2", "jquery": "3.4.1", "lodash": "4.17.15", "moment": "2.24.0", @@ -45,6 +47,7 @@ "react-storybook-addon-props-combinations": "1.1.0", "react-transition-group": "2.6.1", "react-virtualized": "9.21.0", + "slate": "0.47.8", "tinycolor2": "1.4.1" }, "devDependencies": { @@ -65,6 +68,8 @@ "@types/react-custom-scrollbars": "4.0.5", "@types/react-test-renderer": "16.8.1", "@types/react-transition-group": "2.0.16", + "@types/slate": "0.47.1", + "@types/slate-react": "0.22.5", "@types/storybook__addon-actions": "3.4.2", "@types/storybook__addon-info": "4.1.1", "@types/storybook__addon-knobs": "4.0.4", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 85564fa54e02..c79a2084fdd1 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -1,6 +1,6 @@ import resolve from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; -import sourceMaps from 'rollup-plugin-sourcemaps'; +// import sourceMaps from 'rollup-plugin-sourcemaps'; import { terser } from 'rollup-plugin-terser'; const pkg = require('./package.json'); @@ -47,19 +47,20 @@ const buildCjsPackage = ({ env }) => { ], '../../node_modules/react-color/lib/components/common': ['Saturation', 'Hue', 'Alpha'], '../../node_modules/immutable/dist/immutable.js': [ + 'Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack', - 'Record', ], + 'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'], '../../node_modules/esrever/esrever.js': ['reverse'], }, }), resolve(), - sourceMaps(), + // sourceMaps(), env === 'production' && terser(), ], }; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index a9c15f4be7b8..5b57c51aa758 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -1,19 +1,16 @@ import React, { useState, useMemo, useCallback, useContext } from 'react'; import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions'; -import { makeValue, ThemeContext, DataLinkBuiltInVars } from '../../index'; +import { makeValue, ThemeContext, DataLinkBuiltInVars, SCHEMA } from '../../index'; import { SelectionReference } from './SelectionReference'; import { Portal } from '../index'; -// @ts-ignore -import { Editor } from 'slate-react'; -// @ts-ignore -import { Value, Change, Document } from 'slate'; -// @ts-ignore +import { Editor } from '@grafana/slate-react'; +import { Value, Editor as CoreEditor } from 'slate'; import Plain from 'slate-plain-serializer'; import { Popper as ReactPopper } from 'react-popper'; import useDebounce from 'react-use/lib/useDebounce'; import { css, cx } from 'emotion'; -// @ts-ignore -import PluginPrism from 'slate-prism'; + +import { SlatePrism } from '../../slate-plugins'; interface DataLinkInputProps { value: string; @@ -22,7 +19,7 @@ interface DataLinkInputProps { } const plugins = [ - PluginPrism({ + SlatePrism({ onlyIn: (node: any) => node.type === 'code_block', getSyntax: () => 'links', }), @@ -79,27 +76,28 @@ export const DataLinkInput: React.FC = ({ value, onChange, s useDebounce(updateUsedSuggestions, 250, [linkUrl]); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Backspace' || event.key === 'Escape') { + const onKeyDown = (event: Event, editor: CoreEditor, next: Function) => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key === 'Backspace') { setShowingSuggestions(false); setSuggestionsIndex(0); } - if (event.key === 'Enter') { + if (keyboardEvent.key === 'Enter') { if (showingSuggestions) { onVariableSelect(currentSuggestions[suggestionsIndex]); } } if (showingSuggestions) { - if (event.key === 'ArrowDown') { - event.preventDefault(); + if (keyboardEvent.key === 'ArrowDown') { + keyboardEvent.preventDefault(); setSuggestionsIndex(index => { return (index + 1) % currentSuggestions.length; }); } - if (event.key === 'ArrowUp') { - event.preventDefault(); + if (keyboardEvent.key === 'ArrowUp') { + keyboardEvent.preventDefault(); setSuggestionsIndex(index => { const nextIndex = index - 1 < 0 ? currentSuggestions.length - 1 : (index - 1) % currentSuggestions.length; return nextIndex; @@ -107,21 +105,24 @@ export const DataLinkInput: React.FC = ({ value, onChange, s } } - if (event.key === '?' || event.key === '&' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { + if ( + keyboardEvent.key === '?' || + keyboardEvent.key === '&' || + keyboardEvent.key === '$' || + (keyboardEvent.keyCode === 32 && keyboardEvent.ctrlKey) + ) { setShowingSuggestions(true); } - if (event.key === 'Enter' && showingSuggestions) { - // Preventing entering a new line - // As of https://github.com/ianstormtaylor/slate/issues/1345#issuecomment-340508289 - return false; + if (keyboardEvent.key === 'Backspace') { + return next(); } else { // @ts-ignore return; } }; - const onUrlChange = ({ value }: Change) => { + const onUrlChange = ({ value }: { value: Value }) => { setLinkUrl(value); }; @@ -186,6 +187,7 @@ export const DataLinkInput: React.FC = ({ value, onChange, s )} { + if (!opts.onlyIn(node)) { + return next(); + } + return decorateNode(opts, Block.create(node as Block)); + }, + + renderDecoration: (props, editor, next) => + opts.renderDecoration( + { + children: props.children, + decoration: props.decoration, + }, + editor as any, + next + ), + }; +} + +/** + * Returns the decoration for a node + */ +function decorateNode(opts: Options, block: Block) { + const grammarName = opts.getSyntax(block); + const grammar = Prism.languages[grammarName]; + if (!grammar) { + // Grammar not loaded + return []; + } + + // Tokenize the whole block text + const texts = block.getTexts(); + const blockText = texts.map(text => text && text.getText()).join('\n'); + const tokens = Prism.tokenize(blockText, grammar); + + // The list of decorations to return + const decorations: Decoration[] = []; + let textStart = 0; + let textEnd = 0; + + texts.forEach(text => { + textEnd = textStart + text!.getText().length; + + let offset = 0; + function processToken(token: string | Prism.Token, accu?: string | number) { + if (typeof token === 'string') { + if (accu) { + const decoration = createDecoration({ + text: text!, + textStart, + textEnd, + start: offset, + end: offset + token.length, + className: `prism-token token ${accu}`, + block, + }); + if (decoration) { + decorations.push(decoration); + } + } + offset += token.length; + } else { + accu = `${accu} ${token.type} ${token.alias || ''}`; + + if (typeof token.content === 'string') { + const decoration = createDecoration({ + text: text!, + textStart, + textEnd, + start: offset, + end: offset + token.content.length, + className: `prism-token token ${accu}`, + block, + }); + if (decoration) { + decorations.push(decoration); + } + + offset += token.content.length; + } else { + // When using token.content instead of token.matchedStr, token can be deep + for (let i = 0; i < token.content.length; i += 1) { + // @ts-ignore + processToken(token.content[i], accu); + } + } + } + } + + tokens.forEach(processToken); + textStart = textEnd + 1; // account for added `\n` + }); + + return decorations; +} + +/** + * Return a decoration range for the given text. + */ +function createDecoration({ + text, + textStart, + textEnd, + start, + end, + className, + block, +}: { + text: Text; // The text being decorated + textStart: number; // Its start position in the whole text + textEnd: number; // Its end position in the whole text + start: number; // The position in the whole text where the token starts + end: number; // The position in the whole text where the token ends + className: string; // The prism token classname + block: Block; +}): Decoration | null { + if (start >= textEnd || end <= textStart) { + // Ignore, the token is not in the text + return null; + } + + // Shrink to this text boundaries + start = Math.max(start, textStart); + end = Math.min(end, textEnd); + + // Now shift offsets to be relative to this text + start -= textStart; + end -= textStart; + + const myDec = block.createDecoration({ + object: 'decoration', + anchor: { + key: text.key, + offset: start, + object: 'point', + }, + focus: { + key: text.key, + offset: end, + object: 'point', + }, + type: TOKEN_MARK, + data: { className }, + }); + + return myDec; +} diff --git a/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx b/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx new file mode 100644 index 000000000000..82320a5a1321 --- /dev/null +++ b/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Mark, Node, Decoration } from 'slate'; +import { Editor } from '@grafana/slate-react'; +import { Record } from 'immutable'; + +import TOKEN_MARK from './TOKEN_MARK'; + +export interface OptionsFormat { + // Determine which node should be highlighted + onlyIn?: (node: Node) => boolean; + // Returns the syntax for a node that should be highlighted + getSyntax?: (node: Node) => string; + // Render a highlighting mark in a highlighted node + renderMark?: ({ mark, children }: { mark: Mark; children: React.ReactNode }) => void | React.ReactNode; +} + +/** + * Default filter for code blocks + */ +function defaultOnlyIn(node: Node): boolean { + return node.object === 'block' && node.type === 'code_block'; +} + +/** + * Default getter for syntax + */ +function defaultGetSyntax(node: Node): string { + return 'javascript'; +} + +/** + * Default rendering for decorations + */ +function defaultRenderDecoration( + props: { children: React.ReactNode; decoration: Decoration }, + editor: Editor, + next: () => any +): void | React.ReactNode { + const { decoration } = props; + if (decoration.type !== TOKEN_MARK) { + return next(); + } + + const className = decoration.data.get('className'); + return {props.children}; +} + +/** + * The plugin options + */ +class Options + extends Record({ + onlyIn: defaultOnlyIn, + getSyntax: defaultGetSyntax, + renderDecoration: defaultRenderDecoration, + }) + implements OptionsFormat { + readonly onlyIn!: (node: Node) => boolean; + readonly getSyntax!: (node: Node) => string; + readonly renderDecoration!: ( + { + decoration, + children, + }: { + decoration: Decoration; + children: React.ReactNode; + }, + editor: Editor, + next: () => any + ) => void | React.ReactNode; + + constructor(props: OptionsFormat) { + super(props); + } +} + +export default Options; diff --git a/packages/grafana-ui/src/utils/slate.ts b/packages/grafana-ui/src/utils/slate.ts index e8a8dd71295b..fcff5e431077 100644 --- a/packages/grafana-ui/src/utils/slate.ts +++ b/packages/grafana-ui/src/utils/slate.ts @@ -1,22 +1,22 @@ -// @ts-ignore -import { Block, Document, Text, Value } from 'slate'; +import { Block, Document, Text, Value, SchemaProperties } from 'slate'; -const SCHEMA = { - blocks: { - paragraph: 'paragraph', - codeblock: 'code_block', - codeline: 'code_line', +export const SCHEMA: SchemaProperties = { + document: { + nodes: [ + { + match: [{ type: 'paragraph' }, { type: 'code_block' }, { type: 'code_line' }], + }, + ], }, inlines: {}, - marks: {}, }; -export const makeFragment = (text: string, syntax?: string) => { +export const makeFragment = (text: string, syntax?: string): Document => { const lines = text.split('\n').map(line => Block.create({ type: 'code_line', nodes: [Text.create(line)], - } as any) + }) ); const block = Block.create({ @@ -25,18 +25,17 @@ export const makeFragment = (text: string, syntax?: string) => { }, type: 'code_block', nodes: lines, - } as any); + }); return Document.create({ nodes: [block], }); }; -export const makeValue = (text: string, syntax?: string) => { +export const makeValue = (text: string, syntax?: string): Value => { const fragment = makeFragment(text, syntax); return Value.create({ document: fragment, - SCHEMA, - } as any); + }); }; diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index d6dbfc1e0b73..883bbe99ab1c 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/packages/grafana-ui/tsconfig.json @@ -5,6 +5,10 @@ "compilerOptions": { "rootDirs": [".", "stories"], "typeRoots": ["./node_modules/@types", "types"], + "baseUrl": "./node_modules/@types", + "paths": { + "@grafana/slate-react": ["slate-react"] + }, "declarationDir": "dist", "outDir": "compiled" } diff --git a/public/app/features/explore/QueryField.test.tsx b/public/app/features/explore/QueryField.test.tsx index 274de4e7ceb0..e09f00a7b9e9 100644 --- a/public/app/features/explore/QueryField.test.tsx +++ b/public/app/features/explore/QueryField.test.tsx @@ -17,45 +17,4 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.find('div').exists()).toBeTruthy(); }); - - it('should execute query when enter is pressed and there are no suggestions visible', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as QueryField; - instance.executeOnChangeAndRunQueries = jest.fn(); - const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey'); - instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {}); - expect(handleEnterAndTabKeySpy).toBeCalled(); - expect(instance.executeOnChangeAndRunQueries).toBeCalled(); - }); - - it('should copy selected text', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as QueryField; - const textBlocks = ['ignore this text. copy this text']; - const copiedText = instance.getCopiedText(textBlocks, 18, 32); - - expect(copiedText).toBe('copy this text'); - }); - - it('should copy selected text across 2 lines', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as QueryField; - const textBlocks = ['ignore this text. start copying here', 'lorem ipsum. stop copying here. lorem ipsum']; - const copiedText = instance.getCopiedText(textBlocks, 18, 30); - - expect(copiedText).toBe('start copying here\nlorem ipsum. stop copying here'); - }); - - it('should copy selected text across > 2 lines', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as QueryField; - const textBlocks = [ - 'ignore this text. start copying here', - 'lorem ipsum doler sit amet', - 'lorem ipsum. stop copying here. lorem ipsum', - ]; - const copiedText = instance.getCopiedText(textBlocks, 18, 30); - - expect(copiedText).toBe('start copying here\nlorem ipsum doler sit amet\nlorem ipsum. stop copying here'); - }); }); diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 8a61a4397e80..d2d58055c5ff 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -1,55 +1,36 @@ import _ from 'lodash'; import React, { Context } from 'react'; -import ReactDOM from 'react-dom'; -// @ts-ignore -import { Change, Range, Value, Block } from 'slate'; -// @ts-ignore -import { Editor } from 'slate-react'; -// @ts-ignore + +import { Value, Editor as CoreEditor } from 'slate'; +import { Editor, Plugin } from '@grafana/slate-react'; import Plain from 'slate-plain-serializer'; import classnames from 'classnames'; -// @ts-ignore -import { isKeyHotkey } from 'is-hotkey'; -import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; +import { CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; import ClearPlugin from './slate-plugins/clear'; import NewlinePlugin from './slate-plugins/newline'; +import SelectionShortcutsPlugin from './slate-plugins/selection_shortcuts'; +import IndentationPlugin from './slate-plugins/indentation'; +import ClipboardPlugin from './slate-plugins/clipboard'; +import RunnerPlugin from './slate-plugins/runner'; +import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions'; -import { TypeaheadWithTheme } from './Typeahead'; -import { makeFragment, makeValue } from '@grafana/ui'; +import { Typeahead } from './Typeahead'; -export const TYPEAHEAD_DEBOUNCE = 100; -export const HIGHLIGHT_WAIT = 500; -const SLATE_TAB = ' '; -const isIndentLeftHotkey = isKeyHotkey('mod+['); -const isIndentRightHotkey = isKeyHotkey('mod+]'); -const isSelectLeftHotkey = isKeyHotkey('shift+left'); -const isSelectRightHotkey = isKeyHotkey('shift+right'); -const isSelectUpHotkey = isKeyHotkey('shift+up'); -const isSelectDownHotkey = isKeyHotkey('shift+down'); -const isSelectLineHotkey = isKeyHotkey('mod+l'); - -function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem { - // Flatten suggestion groups - const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []); - const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length; - return flattenedSuggestions[correctedIndex]; -} +import { makeValue, SCHEMA } from '@grafana/ui'; -function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { - return suggestions && suggestions.length > 0; -} +export const HIGHLIGHT_WAIT = 500; export interface QueryFieldProps { - additionalPlugins?: any[]; + additionalPlugins?: Plugin[]; cleanText?: (text: string) => string; disabled?: boolean; initialQuery: string | null; onRunQuery?: () => void; onChange?: (value: string) => void; - onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput; - onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string; + onTypeahead?: (typeahead: TypeaheadInput) => Promise; + onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; placeholder?: string; portalOrigin?: string; syntax?: string; @@ -59,20 +40,19 @@ export interface QueryFieldProps { export interface QueryFieldState { suggestions: CompletionItemGroup[]; typeaheadContext: string | null; - typeaheadIndex: number; typeaheadPrefix: string; typeaheadText: string; - value: any; + value: Value; lastExecutedValue: Value; } export interface TypeaheadInput { - editorNode: Element; prefix: string; selection?: Selection; text: string; value: Value; - wrapperNode: Element; + wrapperClasses: string[]; + labelKey?: string; } /** @@ -83,23 +63,35 @@ export interface TypeaheadInput { */ export class QueryField extends React.PureComponent { menuEl: HTMLElement | null; - plugins: any[]; - resetTimer: any; + plugins: Plugin[]; + resetTimer: NodeJS.Timer; mounted: boolean; - updateHighlightsTimer: any; + updateHighlightsTimer: Function; + editor: Editor; + typeaheadRef: Typeahead; constructor(props: QueryFieldProps, context: Context) { super(props, context); this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT); + const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props; + // Base plugins - this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p); + this.plugins = [ + SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }), + ClearPlugin(), + RunnerPlugin({ handler: this.executeOnChangeAndRunQueries }), + NewlinePlugin(), + SelectionShortcutsPlugin(), + IndentationPlugin(), + ClipboardPlugin(), + ...(props.additionalPlugins || []), + ].filter(p => p); this.state = { suggestions: [], typeaheadContext: null, - typeaheadIndex: 0, typeaheadPrefix: '', typeaheadText: '', value: makeValue(props.initialQuery || '', props.syntax), @@ -109,7 +101,6 @@ export class QueryField extends React.PureComponent { + onChange = (value: Value, invokeParentOnValueChanged?: boolean) => { const documentChanged = value.document !== this.state.value.document; const prevValue = this.state.value; @@ -163,14 +145,6 @@ export class QueryField extends React.PureComponent { @@ -194,475 +168,18 @@ export class QueryField extends React.PureComponent { - const selection = window.getSelection(); - const { cleanText, onTypeahead } = this.props; - const { value } = this.state; - - if (onTypeahead && selection.anchorNode) { - const wrapperNode = selection.anchorNode.parentElement; - const editorNode = wrapperNode.closest('.slate-query-field'); - if (!editorNode || this.state.value.isBlurred) { - // Not inside this editor - return; - } - - const range = selection.getRangeAt(0); - const offset = range.startOffset; - const text = selection.anchorNode.textContent; - let prefix = text.substr(0, offset); - - // Label values could have valid characters erased if `cleanText()` is - // blindly applied, which would undesirably interfere with suggestions - const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); - if (labelValueMatch) { - prefix = labelValueMatch[1]; - } else if (cleanText) { - prefix = cleanText(prefix); - } - - const { suggestions, context, refresher } = onTypeahead({ - editorNode, - prefix, - selection, - text, - value, - wrapperNode, - }); - - let filteredSuggestions = suggestions - .map(group => { - if (group.items) { - if (prefix) { - // Filter groups based on prefix - if (!group.skipFilter) { - group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); - if (group.prefixMatch) { - group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0); - } else { - group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1); - } - } - // Filter out the already typed value (prefix) unless it inserts custom text - group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); - } - - if (!group.skipSort) { - group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); - } - } - return group; - }) - .filter(group => group.items && group.items.length > 0); // Filter out empty groups - - // Keep same object for equality checking later - if (_.isEqual(filteredSuggestions, this.state.suggestions)) { - filteredSuggestions = this.state.suggestions; - } - - this.setState( - { - suggestions: filteredSuggestions, - typeaheadPrefix: prefix, - typeaheadContext: context, - typeaheadText: text, - }, - () => { - if (refresher) { - refresher.then(this.handleTypeahead).catch(e => console.error(e)); - } - } - ); - } - }, TYPEAHEAD_DEBOUNCE); - - applyTypeahead(change: Change, suggestion: CompletionItem): Change { - const { cleanText, onWillApplySuggestion, syntax } = this.props; - const { typeaheadPrefix, typeaheadText } = this.state; - let suggestionText = suggestion.insertText || suggestion.label; - const preserveSuffix = suggestion.kind === 'function'; - const move = suggestion.move || 0; - - if (onWillApplySuggestion) { - suggestionText = onWillApplySuggestion(suggestionText, { ...this.state }); - } - - this.resetTypeahead(); - - // Remove the current, incomplete text and replace it with the selected suggestion - const backward = suggestion.deleteBackwards || typeaheadPrefix.length; - const text = cleanText ? cleanText(typeaheadText) : typeaheadText; - const suffixLength = text.length - typeaheadPrefix.length; - const offset = typeaheadText.indexOf(typeaheadPrefix); - const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); - const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; - - // If new-lines, apply suggestion as block - if (suggestionText.match(/\n/)) { - const fragment = makeFragment(suggestionText, syntax); - return change - .deleteBackward(backward) - .deleteForward(forward) - .insertFragment(fragment) - .focus(); - } - - return change - .deleteBackward(backward) - .deleteForward(forward) - .insertText(suggestionText) - .move(move) - .focus(); - } - - handleEnterKey = (event: KeyboardEvent, change: Change) => { - event.preventDefault(); - - if (event.shiftKey) { - // pass through if shift is pressed - return undefined; - } else if (!this.menuEl) { - this.executeOnChangeAndRunQueries(); - return true; - } else { - return this.selectSuggestion(change); - } - }; - - selectSuggestion = (change: Change) => { - const { typeaheadIndex, suggestions } = this.state; - event.preventDefault(); - - if (!suggestions || suggestions.length === 0) { - return undefined; - } - - const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex); - const nextChange = this.applyTypeahead(change, suggestion); - - const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text'); - return insertTextOperation ? true : undefined; - }; - - handleTabKey = (change: Change): void => { - const { - startBlock, - endBlock, - selection: { startOffset, startKey, endOffset, endKey }, - } = change.value; - - if (this.menuEl) { - this.selectSuggestion(change); - return; - } - - const first = startBlock.getFirstText(); - - const startBlockIsSelected = - startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key; - - if (startBlockIsSelected || !startBlock.equals(endBlock)) { - this.handleIndent(change, 'right'); - } else { - change.insertText(SLATE_TAB); - } - }; - - handleIndent = (change: Change, indentDirection: 'left' | 'right') => { - const curSelection = change.value.selection; - const selectedBlocks = change.value.document.getBlocksAtRange(curSelection); - - if (indentDirection === 'left') { - for (const block of selectedBlocks) { - const blockWhitespace = block.text.length - block.text.trimLeft().length; - - const rangeProperties = { - anchorKey: block.getFirstText().key, - anchorOffset: blockWhitespace, - focusKey: block.getFirstText().key, - focusOffset: blockWhitespace, - }; - - // @ts-ignore - const whitespaceToDelete = Range.create(rangeProperties); - - change.deleteBackwardAtRange(whitespaceToDelete, Math.min(SLATE_TAB.length, blockWhitespace)); - } - } else { - const { startText } = change.value; - const textBeforeCaret = startText.text.slice(0, curSelection.startOffset); - const isWhiteSpace = /^\s*$/.test(textBeforeCaret); - - for (const block of selectedBlocks) { - change.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB); - } - - if (isWhiteSpace) { - change.moveStart(-SLATE_TAB.length); - } - } - }; - - handleSelectVertical = (change: Change, direction: 'up' | 'down') => { - const { focusBlock } = change.value; - const adjacentBlock = - direction === 'up' - ? change.value.document.getPreviousBlock(focusBlock.key) - : change.value.document.getNextBlock(focusBlock.key); - - if (!adjacentBlock) { - return true; - } - const adjacentText = adjacentBlock.getFirstText(); - change.moveFocusTo(adjacentText.key, Math.min(change.value.anchorOffset, adjacentText.text.length)).focus(); - return true; - }; - - handleSelectUp = (change: Change) => this.handleSelectVertical(change, 'up'); - - handleSelectDown = (change: Change) => this.handleSelectVertical(change, 'down'); - - onKeyDown = (event: KeyboardEvent, change: Change) => { - const { typeaheadIndex } = this.state; - - // Shortcuts - if (isIndentLeftHotkey(event)) { - event.preventDefault(); - this.handleIndent(change, 'left'); - return true; - } else if (isIndentRightHotkey(event)) { - event.preventDefault(); - this.handleIndent(change, 'right'); - return true; - } else if (isSelectLeftHotkey(event)) { - event.preventDefault(); - if (change.value.focusOffset > 0) { - change.moveFocus(-1); - } - return true; - } else if (isSelectRightHotkey(event)) { - event.preventDefault(); - if (change.value.focusOffset < change.value.startText.text.length) { - change.moveFocus(1); - } - return true; - } else if (isSelectUpHotkey(event)) { - event.preventDefault(); - this.handleSelectUp(change); - return true; - } else if (isSelectDownHotkey(event)) { - event.preventDefault(); - this.handleSelectDown(change); - return true; - } else if (isSelectLineHotkey(event)) { - event.preventDefault(); - const { focusBlock, document } = change.value; - - change.moveAnchorToStartOfBlock(focusBlock.key); - - const nextBlock = document.getNextBlock(focusBlock.key); - if (nextBlock) { - change.moveFocusToStartOfNextBlock(); - } else { - change.moveFocusToEndOfText(); - } - - return true; - } - - switch (event.key) { - case 'Escape': { - if (this.menuEl) { - event.preventDefault(); - event.stopPropagation(); - this.resetTypeahead(); - return true; - } - break; - } - - case ' ': { - if (event.ctrlKey) { - event.preventDefault(); - this.handleTypeahead(); - return true; - } - break; - } - - case 'Enter': - return this.handleEnterKey(event, change); - - case 'Tab': { - event.preventDefault(); - return this.handleTabKey(change); - } - - case 'ArrowDown': { - if (this.menuEl) { - // Select next suggestion - event.preventDefault(); - const itemsCount = - this.state.suggestions.length > 0 - ? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0) - : 0; - this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) }); - } - break; - } - - case 'ArrowUp': { - if (this.menuEl) { - // Select previous suggestion - event.preventDefault(); - this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); - } - break; - } - - default: { - // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key); - break; - } - } - return undefined; - }; - - resetTypeahead = () => { - if (this.mounted) { - this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null }); - this.resetTimer = null; - } - }; - - handleBlur = (event: FocusEvent, change: Change) => { + handleBlur = (event: Event, editor: CoreEditor, next: Function) => { const { lastExecutedValue } = this.state; const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null; - const currentValue = Plain.serialize(change.value); - - // If we dont wait here, menu clicks wont work because the menu - // will be gone. - this.resetTimer = setTimeout(this.resetTypeahead, 100); + const currentValue = Plain.serialize(editor.value); if (previousValue !== currentValue) { this.executeOnChangeAndRunQueries(); } - }; - - onClickMenu = (item: CompletionItem) => { - // Manually triggering change - const change = this.applyTypeahead(this.state.value.change(), item); - this.onChange(change, true); - }; - - updateMenu = () => { - const { suggestions } = this.state; - const menu = this.menuEl; - // Exit for unit tests - if (!window.getSelection) { - return; - } - const selection = window.getSelection(); - const node = selection.anchorNode; - - // No menu, nothing to do - if (!menu) { - return; - } - - // No suggestions or blur, remove menu - if (!hasSuggestions(suggestions)) { - menu.removeAttribute('style'); - return; - } - - // Align menu overlay to editor node - if (node) { - // Read from DOM - const rect = node.parentElement.getBoundingClientRect(); - const scrollX = window.scrollX; - const scrollY = window.scrollY; - - // Write DOM - requestAnimationFrame(() => { - menu.style.opacity = '1'; - menu.style.top = `${rect.top + scrollY + rect.height + 4}px`; - menu.style.left = `${rect.left + scrollX - 2}px`; - }); - } - }; - - menuRef = (el: HTMLElement) => { - this.menuEl = el; - }; - - renderMenu = () => { - const { portalOrigin } = this.props; - const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state; - if (!hasSuggestions(suggestions)) { - return null; - } - - const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex); - - // Create typeahead in DOM root so we can later position it absolutely - return ( - - - - ); - }; - - getCopiedText(textBlocks: string[], startOffset: number, endOffset: number) { - if (!textBlocks.length) { - return undefined; - } - - const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1; - return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset); - } - - handleCopy = (event: ClipboardEvent, change: Change) => { - event.preventDefault(); - - const { document, selection, startOffset, endOffset } = change.value; - const selectedBlocks = document.getBlocksAtRangeAsArray(selection).map((block: Block) => block.text); - - const copiedText = this.getCopiedText(selectedBlocks, startOffset, endOffset); - if (copiedText) { - event.clipboardData.setData('Text', copiedText); - } - - return true; - }; - - handlePaste = (event: ClipboardEvent, change: Change) => { - event.preventDefault(); - const pastedValue = event.clipboardData.getData('Text'); - const lines = pastedValue.split('\n'); - - if (lines.length) { - change.insertText(lines[0]); - for (const line of lines.slice(1)) { - change.splitBlock().insertText(line); - } - } - - return true; - }; - handleCut = (event: ClipboardEvent, change: Change) => { - this.handleCopy(event, change); - change.deleteAtRange(change.value.selection); + editor.blur(); - return true; + return next(); }; render() { @@ -670,19 +187,20 @@ export class QueryField extends React.PureComponent
- {this.renderMenu()} (this.editor = editor)} + schema={SCHEMA} autoCorrect={false} readOnly={this.props.disabled} onBlur={this.handleBlur} - onKeyDown={this.onKeyDown} - onChange={this.onChange} - onCopy={this.handleCopy} - onPaste={this.handlePaste} - onCut={this.handleCut} + // onKeyDown={this.onKeyDown} + onChange={(change: { value: Value }) => { + this.onChange(change.value, false); + }} placeholder={this.props.placeholder} plugins={this.plugins} spellCheck={false} @@ -694,29 +212,4 @@ export class QueryField extends React.PureComponent { - node: HTMLElement; - - constructor(props: PortalProps) { - super(props); - const { index = 0, origin = 'query' } = props; - this.node = document.createElement('div'); - this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`); - document.body.appendChild(this.node); - } - - componentWillUnmount() { - document.body.removeChild(this.node); - } - - render() { - return ReactDOM.createPortal(this.props.children, this.node); - } -} - export default QueryField; diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index b28ab4a610d3..91e675d7eb8b 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/public/app/features/explore/Typeahead.tsx @@ -1,21 +1,24 @@ -import React, { createRef } from 'react'; +import React, { createRef, CSSProperties } from 'react'; +import ReactDOM from 'react-dom'; import _ from 'lodash'; import { FixedSizeList } from 'react-window'; import { Themeable, withTheme } from '@grafana/ui'; -import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; +import { CompletionItem, CompletionItemKind, CompletionItemGroup } from 'app/types/explore'; import { TypeaheadItem } from './TypeaheadItem'; import { TypeaheadInfo } from './TypeaheadInfo'; import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead'; +const modulo = (a: number, n: number) => a - n * Math.floor(a / n); + interface Props extends Themeable { + origin: string; groupedItems: CompletionItemGroup[]; - menuRef: any; - selectedItem: CompletionItem | null; - onClickItem: (suggestion: CompletionItem) => void; prefix?: string; - typeaheadIndex: number; + menuRef?: (el: Typeahead) => void; + onSelectSuggestion?: (suggestion: CompletionItem) => void; + isOpen?: boolean; } interface State { @@ -23,11 +26,12 @@ interface State { listWidth: number; listHeight: number; itemHeight: number; + hoveredItem: number; + typeaheadIndex: number; } export class Typeahead extends React.PureComponent { - listRef: any = createRef(); - documentationRef: any = createRef(); + listRef = createRef(); constructor(props: Props) { super(props); @@ -35,97 +39,173 @@ export class Typeahead extends React.PureComponent { const allItems = flattenGroupItems(props.groupedItems); const longestLabel = calculateLongestLabel(allItems); const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel); - this.state = { listWidth, listHeight, itemHeight, allItems }; + this.state = { listWidth, listHeight, itemHeight, hoveredItem: null, typeaheadIndex: 1, allItems }; } - componentDidUpdate = (prevProps: Readonly) => { - if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) { - if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) { + componentDidMount = () => { + this.props.menuRef(this); + }; + + componentDidUpdate = (prevProps: Readonly, prevState: Readonly) => { + if (prevState.typeaheadIndex !== this.state.typeaheadIndex && this.listRef && this.listRef.current) { + if (this.state.typeaheadIndex === 1) { this.listRef.current.scrollToItem(0); // special case for handling the first group label - this.refreshDocumentation(); return; } - const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); - this.listRef.current.scrollToItem(index); - this.refreshDocumentation(); + this.listRef.current.scrollToItem(this.state.typeaheadIndex); } if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) { const allItems = flattenGroupItems(this.props.groupedItems); const longestLabel = calculateLongestLabel(allItems); const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel); - this.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation()); + this.setState({ listWidth, listHeight, itemHeight, allItems }); } }; - refreshDocumentation = () => { - if (!this.documentationRef.current) { - return; - } + onMouseEnter = (index: number) => { + this.setState({ + hoveredItem: index, + }); + }; + + onMouseLeave = () => { + this.setState({ + hoveredItem: null, + }); + }; + + moveMenuIndex = (moveAmount: number) => { + const itemCount = this.state.allItems.length; + if (itemCount) { + // Select next suggestion + event.preventDefault(); + let newTypeaheadIndex = modulo(this.state.typeaheadIndex + moveAmount, itemCount); - const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); - const item = this.state.allItems[index]; + if (this.state.allItems[newTypeaheadIndex].kind === CompletionItemKind.GroupTitle) { + newTypeaheadIndex = modulo(newTypeaheadIndex + moveAmount, itemCount); + } - if (item) { - this.documentationRef.current.refresh(item); + this.setState({ + typeaheadIndex: newTypeaheadIndex, + }); + + return; } }; - onMouseEnter = (item: CompletionItem) => { - this.documentationRef.current.refresh(item); + insertSuggestion = () => { + this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]); }; - onMouseLeave = () => { - this.documentationRef.current.hide(); - }; + get menuPosition(): CSSProperties { + // Exit for unit tests + if (!window.getSelection) { + return {}; + } + + const selection = window.getSelection(); + const node = selection.anchorNode; + + // Align menu overlay to editor node + if (node) { + // Read from DOM + const rect = node.parentElement.getBoundingClientRect(); + const scrollX = window.scrollX; + const scrollY = window.scrollY; + + return { + top: `${rect.top + scrollY + rect.height + 4}px`, + left: `${rect.left + scrollX - 2}px`, + }; + } + + return {}; + } render() { - const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props; - const { listWidth, listHeight, itemHeight, allItems } = this.state; + const { prefix, theme, isOpen, origin } = this.props; + const { allItems, listWidth, listHeight, itemHeight, hoveredItem, typeaheadIndex } = this.state; + + const showDocumentation = hoveredItem || typeaheadIndex; return ( -
    - - { - const item = allItems && allItems[index]; - const key = item ? `${index}-${item.label}` : `${index}`; - return key; - }} - width={listWidth} - height={listHeight} - > - {({ index, style }) => { - const item = allItems && allItems[index]; - if (!item) { - return null; - } - - return ( - - ); - }} - -
+ +
    + { + const item = allItems && allItems[index]; + const key = item ? `${index}-${item.label}` : `${index}`; + return key; + }} + width={listWidth} + height={listHeight} + > + {({ index, style }) => { + const item = allItems && allItems[index]; + if (!item) { + return null; + } + + return ( + this.props.onSelectSuggestion(item)} + isSelected={allItems[typeaheadIndex] === item} + item={item} + prefix={prefix} + style={style} + onMouseEnter={() => this.onMouseEnter(index)} + onMouseLeave={this.onMouseLeave} + /> + ); + }} + +
+ + {showDocumentation && ( + + )} +
); } } export const TypeaheadWithTheme = withTheme(Typeahead); + +interface PortalProps { + index?: number; + isOpen: boolean; + origin: string; +} + +class Portal extends React.PureComponent { + node: HTMLElement; + + constructor(props: PortalProps) { + super(props); + const { index = 0, origin = 'query' } = props; + this.node = document.createElement('div'); + this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`); + document.body.appendChild(this.node); + } + + componentWillUnmount() { + document.body.removeChild(this.node); + } + + render() { + if (this.props.isOpen) { + return ReactDOM.createPortal(this.props.children, this.node); + } + + return null; + } +} diff --git a/public/app/features/explore/TypeaheadInfo.tsx b/public/app/features/explore/TypeaheadInfo.tsx index 4b410c8b3659..f18edcee17db 100644 --- a/public/app/features/explore/TypeaheadInfo.tsx +++ b/public/app/features/explore/TypeaheadInfo.tsx @@ -1,29 +1,26 @@ import React, { PureComponent } from 'react'; -import { Themeable, selectThemeVariant } from '@grafana/ui'; import { css, cx } from 'emotion'; +import { Themeable, selectThemeVariant } from '@grafana/ui'; + import { CompletionItem } from 'app/types/explore'; interface Props extends Themeable { - initialItem: CompletionItem; + item: CompletionItem; width: number; height: number; } -interface State { - item: CompletionItem; -} - -export class TypeaheadInfo extends PureComponent { +export class TypeaheadInfo extends PureComponent { constructor(props: Props) { super(props); - this.state = { item: props.initialItem }; } getStyles = (visible: boolean) => { const { width, height, theme } = this.props; const selection = window.getSelection(); const node = selection.anchorNode; + if (!node) { return {}; } @@ -38,7 +35,7 @@ export class TypeaheadInfo extends PureComponent { return { typeaheadItem: css` label: type-ahead-item; - z-index: auto; + z-index: 500; padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md}; border-radius: ${theme.border.radius.md}; border: ${selectThemeVariant( @@ -64,16 +61,8 @@ export class TypeaheadInfo extends PureComponent { }; }; - refresh = (item: CompletionItem) => { - this.setState({ item }); - }; - - hide = () => { - this.setState({ item: null }); - }; - render() { - const { item } = this.state; + const { item } = this.props; const visible = item && !!item.documentation; const label = item ? item.label : ''; const documentation = item && item.documentation ? item.documentation : ''; diff --git a/public/app/features/explore/TypeaheadItem.tsx b/public/app/features/explore/TypeaheadItem.tsx index f670330e44de..e20a57586137 100644 --- a/public/app/features/explore/TypeaheadItem.tsx +++ b/public/app/features/explore/TypeaheadItem.tsx @@ -1,25 +1,21 @@ import React, { FunctionComponent, useContext } from 'react'; + // @ts-ignore import Highlighter from 'react-highlight-words'; import { css, cx } from 'emotion'; import { GrafanaTheme, ThemeContext, selectThemeVariant } from '@grafana/ui'; -import { CompletionItem } from 'app/types/explore'; - -export const GROUP_TITLE_KIND = 'GroupTitle'; - -export const isGroupTitle = (item: CompletionItem) => { - return item.kind && item.kind === GROUP_TITLE_KIND ? true : false; -}; +import { CompletionItem, CompletionItemKind } from 'app/types/explore'; interface Props { isSelected: boolean; item: CompletionItem; - onClickItem: (suggestion: CompletionItem) => void; - prefix?: string; style: any; - onMouseEnter: (item: CompletionItem) => void; - onMouseLeave: (item: CompletionItem) => void; + prefix?: string; + + onClickItem?: (event: React.MouseEvent) => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; } const getStyles = (theme: GrafanaTheme) => ({ @@ -38,10 +34,12 @@ const getStyles = (theme: GrafanaTheme) => ({ transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1); `, + typeaheadItemSelected: css` label: type-ahead-item-selected; background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.dark9 }, theme.type)}; `, + typeaheadItemMatch: css` label: type-ahead-item-match; color: ${theme.colors.yellow}; @@ -49,6 +47,7 @@ const getStyles = (theme: GrafanaTheme) => ({ padding: inherit; background: inherit; `, + typeaheadItemGroupTitle: css` label: type-ahead-item-group-title; color: ${theme.colors.textWeak}; @@ -62,16 +61,13 @@ export const TypeaheadItem: FunctionComponent = (props: Props) => { const theme = useContext(ThemeContext); const styles = getStyles(theme); - const { isSelected, item, prefix, style, onClickItem } = props; - const onClick = () => onClickItem(item); - const onMouseEnter = () => props.onMouseEnter(item); - const onMouseLeave = () => props.onMouseLeave(item); + const { isSelected, item, prefix, style, onMouseEnter, onMouseLeave, onClickItem } = props; const className = isSelected ? cx([styles.typeaheadItem, styles.typeaheadItemSelected]) : cx([styles.typeaheadItem]); const highlightClassName = cx([styles.typeaheadItemMatch]); const itemGroupTitleClassName = cx([styles.typeaheadItemGroupTitle]); const label = item.label || ''; - if (isGroupTitle(item)) { + if (item.kind === CompletionItemKind.GroupTitle) { return (
  • {label} @@ -80,7 +76,13 @@ export const TypeaheadItem: FunctionComponent = (props: Props) => { } return ( -
  • +
  • ); diff --git a/public/app/features/explore/slate-plugins/braces.test.ts b/public/app/features/explore/slate-plugins/braces.test.ts deleted file mode 100644 index d72ea0f3d974..000000000000 --- a/public/app/features/explore/slate-plugins/braces.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-ignore -import Plain from 'slate-plain-serializer'; - -import BracesPlugin from './braces'; - -declare global { - interface Window { - KeyboardEvent: any; - } -} - -describe('braces', () => { - const handler = BracesPlugin().onKeyDown; - - it('adds closing braces around empty value', () => { - const change = Plain.deserialize('').change(); - const event = new window.KeyboardEvent('keydown', { key: '(' }); - handler(event, change); - expect(Plain.serialize(change.value)).toEqual('()'); - }); - - it('removes closing brace when opening brace is removed', () => { - const change = Plain.deserialize('time()').change(); - let event; - change.move(5); - event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); - handler(event, change); - expect(Plain.serialize(change.value)).toEqual('time'); - }); - - it('keeps closing brace when opening brace is removed and inner values exist', () => { - const change = Plain.deserialize('time(value)').change(); - let event; - change.move(5); - event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); - const handled = handler(event, change); - expect(handled).toBeFalsy(); - }); -}); diff --git a/public/app/features/explore/slate-plugins/braces.test.tsx b/public/app/features/explore/slate-plugins/braces.test.tsx new file mode 100644 index 000000000000..a80f67c817f1 --- /dev/null +++ b/public/app/features/explore/slate-plugins/braces.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import Plain from 'slate-plain-serializer'; +import { Editor } from '@grafana/slate-react'; +import { shallow } from 'enzyme'; +import BracesPlugin from './braces'; + +declare global { + interface Window { + KeyboardEvent: any; + } +} + +describe('braces', () => { + const handler = BracesPlugin().onKeyDown; + const nextMock = () => {}; + + it('adds closing braces around empty value', () => { + const value = Plain.deserialize(''); + const editor = shallow(); + const event = new window.KeyboardEvent('keydown', { key: '(' }); + handler(event as Event, editor.instance() as any, nextMock); + expect(Plain.serialize(editor.instance().value)).toEqual('()'); + }); + + it('removes closing brace when opening brace is removed', () => { + const value = Plain.deserialize('time()'); + const editor = shallow(); + const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); + handler(event as Event, editor.instance().moveForward(5) as any, nextMock); + expect(Plain.serialize(editor.instance().value)).toEqual('time'); + }); + + it('keeps closing brace when opening brace is removed and inner values exist', () => { + const value = Plain.deserialize('time(value)'); + const editor = shallow(); + const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); + const handled = handler(event as Event, editor.instance().moveForward(5) as any, nextMock); + expect(handled).toBeFalsy(); + }); +}); diff --git a/public/app/features/explore/slate-plugins/braces.ts b/public/app/features/explore/slate-plugins/braces.ts index ee6227cc309c..0eff1fa7e4f1 100644 --- a/public/app/features/explore/slate-plugins/braces.ts +++ b/public/app/features/explore/slate-plugins/braces.ts @@ -1,5 +1,5 @@ -// @ts-ignore -import { Change } from 'slate'; +import { Plugin } from '@grafana/slate-react'; +import { Editor as CoreEditor } from 'slate'; const BRACES: any = { '[': ']', @@ -7,34 +7,37 @@ const BRACES: any = { '(': ')', }; -export default function BracesPlugin() { +export default function BracesPlugin(): Plugin { return { - onKeyDown(event: KeyboardEvent, change: Change) { - const { value } = change; + onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { + const { value } = editor; switch (event.key) { case '(': case '{': case '[': { event.preventDefault(); - - const { startOffset, startKey, endOffset, endKey, focusOffset } = value.selection; - const text: string = value.focusText.text; + const { + start: { offset: startOffset, key: startKey }, + end: { offset: endOffset, key: endKey }, + focus: { offset: focusOffset }, + } = value.selection; + const text = value.focusText.text; // If text is selected, wrap selected text in parens - if (value.isExpanded) { - change + if (value.selection.isExpanded) { + editor .insertTextByKey(startKey, startOffset, event.key) .insertTextByKey(endKey, endOffset + 1, BRACES[event.key]) - .moveEnd(-1); + .moveEndBackward(1); } else if ( focusOffset === text.length || text[focusOffset] === ' ' || Object.values(BRACES).includes(text[focusOffset]) ) { - change.insertText(`${event.key}${BRACES[event.key]}`).move(-1); + editor.insertText(`${event.key}${BRACES[event.key]}`).moveBackward(1); } else { - change.insertText(event.key); + editor.insertText(event.key); } return true; @@ -42,15 +45,15 @@ export default function BracesPlugin() { case 'Backspace': { const text = value.anchorText.text; - const offset = value.anchorOffset; + const offset = value.selection.anchor.offset; const previousChar = text[offset - 1]; const nextChar = text[offset]; if (BRACES[previousChar] && BRACES[previousChar] === nextChar) { event.preventDefault(); // Remove closing brace if directly following - change - .deleteBackward() - .deleteForward() + editor + .deleteBackward(1) + .deleteForward(1) .focus(); return true; } @@ -60,7 +63,8 @@ export default function BracesPlugin() { break; } } - return undefined; + + return next(); }, }; } diff --git a/public/app/features/explore/slate-plugins/clear.test.ts b/public/app/features/explore/slate-plugins/clear.test.ts deleted file mode 100644 index 9322fffd7d2d..000000000000 --- a/public/app/features/explore/slate-plugins/clear.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-ignore -import Plain from 'slate-plain-serializer'; - -import ClearPlugin from './clear'; - -describe('clear', () => { - const handler = ClearPlugin().onKeyDown; - - it('does not change the empty value', () => { - const change = Plain.deserialize('').change(); - const event = new window.KeyboardEvent('keydown', { - key: 'k', - ctrlKey: true, - }); - handler(event, change); - expect(Plain.serialize(change.value)).toEqual(''); - }); - - it('clears to the end of the line', () => { - const change = Plain.deserialize('foo').change(); - const event = new window.KeyboardEvent('keydown', { - key: 'k', - ctrlKey: true, - }); - handler(event, change); - expect(Plain.serialize(change.value)).toEqual(''); - }); - - it('clears from the middle to the end of the line', () => { - const change = Plain.deserialize('foo bar').change(); - change.move(4); - const event = new window.KeyboardEvent('keydown', { - key: 'k', - ctrlKey: true, - }); - handler(event, change); - expect(Plain.serialize(change.value)).toEqual('foo '); - }); -}); diff --git a/public/app/features/explore/slate-plugins/clear.test.tsx b/public/app/features/explore/slate-plugins/clear.test.tsx new file mode 100644 index 000000000000..4565827e3859 --- /dev/null +++ b/public/app/features/explore/slate-plugins/clear.test.tsx @@ -0,0 +1,42 @@ +import Plain from 'slate-plain-serializer'; +import React from 'react'; +import { Editor } from '@grafana/slate-react'; +import { shallow } from 'enzyme'; +import ClearPlugin from './clear'; + +describe('clear', () => { + const handler = ClearPlugin().onKeyDown; + + it('does not change the empty value', () => { + const value = Plain.deserialize(''); + const editor = shallow(); + const event = new window.KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + }); + handler(event as Event, editor.instance() as any, () => {}); + expect(Plain.serialize(editor.instance().value)).toEqual(''); + }); + + it('clears to the end of the line', () => { + const value = Plain.deserialize('foo'); + const editor = shallow(); + const event = new window.KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + }); + handler(event as Event, editor.instance() as any, () => {}); + expect(Plain.serialize(editor.instance().value)).toEqual(''); + }); + + it('clears from the middle to the end of the line', () => { + const value = Plain.deserialize('foo bar'); + const editor = shallow(); + const event = new window.KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + }); + handler(event as Event, editor.instance().moveForward(4) as any, () => {}); + expect(Plain.serialize(editor.instance().value)).toEqual('foo '); + }); +}); diff --git a/public/app/features/explore/slate-plugins/clear.ts b/public/app/features/explore/slate-plugins/clear.ts index 9d649aa69260..83dcf2e27b7b 100644 --- a/public/app/features/explore/slate-plugins/clear.ts +++ b/public/app/features/explore/slate-plugins/clear.ts @@ -1,22 +1,27 @@ +import { Plugin } from '@grafana/slate-react'; +import { Editor as CoreEditor } from 'slate'; + // Clears the rest of the line after the caret -export default function ClearPlugin() { +export default function ClearPlugin(): Plugin { return { - onKeyDown(event: any, change: { value?: any; deleteForward?: any }) { - const { value } = change; - if (!value.isCollapsed) { - return undefined; + onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { + const value = editor.value; + + if (value.selection.isExpanded) { + return next(); } if (event.key === 'k' && event.ctrlKey) { event.preventDefault(); const text = value.anchorText.text; - const offset = value.anchorOffset; + const offset = value.selection.anchor.offset; const length = text.length; const forward = length - offset; - change.deleteForward(forward); + editor.deleteForward(forward); return true; } - return undefined; + + return next(); }, }; } diff --git a/public/app/features/explore/slate-plugins/clipboard.ts b/public/app/features/explore/slate-plugins/clipboard.ts new file mode 100644 index 000000000000..79d277ec65a6 --- /dev/null +++ b/public/app/features/explore/slate-plugins/clipboard.ts @@ -0,0 +1,61 @@ +import { Plugin } from '@grafana/slate-react'; +import { Editor as CoreEditor } from 'slate'; + +const getCopiedText = (textBlocks: string[], startOffset: number, endOffset: number) => { + if (!textBlocks.length) { + return undefined; + } + + const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1; + return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset); +}; + +export default function ClipboardPlugin(): Plugin { + const clipboardPlugin = { + onCopy(event: ClipboardEvent, editor: CoreEditor) { + event.preventDefault(); + + const { document, selection } = editor.value; + const { + start: { offset: startOffset }, + end: { offset: endOffset }, + } = selection; + const selectedBlocks = document + .getLeafBlocksAtRange(selection) + .toArray() + .map(block => block.text); + + const copiedText = getCopiedText(selectedBlocks, startOffset, endOffset); + if (copiedText) { + event.clipboardData.setData('Text', copiedText); + } + + return true; + }, + + onPaste(event: ClipboardEvent, editor: CoreEditor) { + event.preventDefault(); + const pastedValue = event.clipboardData.getData('Text'); + const lines = pastedValue.split('\n'); + + if (lines.length) { + editor.insertText(lines[0]); + for (const line of lines.slice(1)) { + editor.splitBlock().insertText(line); + } + } + + return true; + }, + }; + + return { + ...clipboardPlugin, + onCut(event: ClipboardEvent, editor: CoreEditor) { + clipboardPlugin.onCopy(event, editor); + editor.deleteAtRange(editor.value.selection); + + return true; + }, + }; +} diff --git a/public/app/features/explore/slate-plugins/indentation.ts b/public/app/features/explore/slate-plugins/indentation.ts new file mode 100644 index 000000000000..d3f1ab154c36 --- /dev/null +++ b/public/app/features/explore/slate-plugins/indentation.ts @@ -0,0 +1,93 @@ +import { RangeJSON, Range as SlateRange, Editor as CoreEditor } from 'slate'; +import { Plugin } from '@grafana/slate-react'; +import { isKeyHotkey } from 'is-hotkey'; + +const isIndentLeftHotkey = isKeyHotkey('mod+['); +const isShiftTabHotkey = isKeyHotkey('shift+tab'); +const isIndentRightHotkey = isKeyHotkey('mod+]'); + +const SLATE_TAB = ' '; + +const handleTabKey = (event: KeyboardEvent, editor: CoreEditor, next: Function): void => { + const { + startBlock, + endBlock, + selection: { + start: { offset: startOffset, key: startKey }, + end: { offset: endOffset, key: endKey }, + }, + } = editor.value; + + const first = startBlock.getFirstText(); + + const startBlockIsSelected = + startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key; + + if (startBlockIsSelected || !startBlock.equals(endBlock)) { + handleIndent(editor, 'right'); + } else { + editor.insertText(SLATE_TAB); + } +}; + +const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') => { + const curSelection = editor.value.selection; + const selectedBlocks = editor.value.document.getLeafBlocksAtRange(curSelection).toArray(); + + if (indentDirection === 'left') { + for (const block of selectedBlocks) { + const blockWhitespace = block.text.length - block.text.trimLeft().length; + + const textKey = block.getFirstText().key; + + const rangeProperties: RangeJSON = { + anchor: { + key: textKey, + offset: blockWhitespace, + path: [], + }, + focus: { + key: textKey, + offset: blockWhitespace, + path: [], + }, + }; + + editor.deleteBackwardAtRange(SlateRange.create(rangeProperties), Math.min(SLATE_TAB.length, blockWhitespace)); + } + } else { + const { startText } = editor.value; + const textBeforeCaret = startText.text.slice(0, curSelection.start.offset); + const isWhiteSpace = /^\s*$/.test(textBeforeCaret); + + for (const block of selectedBlocks) { + editor.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB); + } + + if (isWhiteSpace) { + editor.moveStartBackward(SLATE_TAB.length); + } + } +}; + +// Clears the rest of the line after the caret +export default function IndentationPlugin(): Plugin { + return { + onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { + if (isIndentLeftHotkey(event) || isShiftTabHotkey(event)) { + event.preventDefault(); + handleIndent(editor, 'left'); + } else if (isIndentRightHotkey(event)) { + event.preventDefault(); + handleIndent(editor, 'right'); + } else if (event.key === 'Tab') { + event.preventDefault(); + handleTabKey(event, editor, next); + } else { + return next(); + } + + return true; + }, + }; +} diff --git a/public/app/features/explore/slate-plugins/newline.ts b/public/app/features/explore/slate-plugins/newline.ts index a20bb162870d..c31d2a74b18a 100644 --- a/public/app/features/explore/slate-plugins/newline.ts +++ b/public/app/features/explore/slate-plugins/newline.ts @@ -1,7 +1,7 @@ -// @ts-ignore -import { Change } from 'slate'; +import { Plugin } from '@grafana/slate-react'; +import { Editor as CoreEditor } from 'slate'; -function getIndent(text: any) { +function getIndent(text: string) { let offset = text.length - text.trimLeft().length; if (offset) { let indent = text[0]; @@ -13,12 +13,13 @@ function getIndent(text: any) { return ''; } -export default function NewlinePlugin() { +export default function NewlinePlugin(): Plugin { return { - onKeyDown(event: KeyboardEvent, change: Change) { - const { value } = change; - if (!value.isCollapsed) { - return undefined; + onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { + const value = editor.value; + + if (value.selection.isExpanded) { + return next(); } if (event.key === 'Enter' && event.shiftKey) { @@ -28,11 +29,13 @@ export default function NewlinePlugin() { const currentLineText = startBlock.text; const indent = getIndent(currentLineText); - return change + return editor .splitBlock() .insertText(indent) .focus(); } + + return next(); }, }; } diff --git a/public/app/features/explore/slate-plugins/runner.test.tsx b/public/app/features/explore/slate-plugins/runner.test.tsx new file mode 100644 index 000000000000..3604681e03a2 --- /dev/null +++ b/public/app/features/explore/slate-plugins/runner.test.tsx @@ -0,0 +1,17 @@ +import Plain from 'slate-plain-serializer'; +import React from 'react'; +import { Editor } from '@grafana/slate-react'; +import { shallow } from 'enzyme'; +import RunnerPlugin from './runner'; + +describe('runner', () => { + const mockHandler = jest.fn(); + const handler = RunnerPlugin({ handler: mockHandler }).onKeyDown; + + it('should execute query when enter is pressed and there are no suggestions visible', () => { + const value = Plain.deserialize(''); + const editor = shallow(); + handler({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, editor.instance() as any, () => {}); + expect(mockHandler).toBeCalled(); + }); +}); diff --git a/public/app/features/explore/slate-plugins/runner.ts b/public/app/features/explore/slate-plugins/runner.ts index fc7b8a778ed0..bb3a10f87590 100644 --- a/public/app/features/explore/slate-plugins/runner.ts +++ b/public/app/features/explore/slate-plugins/runner.ts @@ -1,6 +1,8 @@ +import { Editor as SlateEditor } from 'slate'; + export default function RunnerPlugin({ handler }: any) { return { - onKeyDown(event: any) { + onKeyDown(event: KeyboardEvent, editor: SlateEditor, next: Function) { // Handle enter if (handler && event.key === 'Enter' && !event.shiftKey) { // Submit on Enter @@ -8,7 +10,8 @@ export default function RunnerPlugin({ handler }: any) { handler(event); return true; } - return undefined; + + return next(); }, }; } diff --git a/public/app/features/explore/slate-plugins/selection_shortcuts.ts b/public/app/features/explore/slate-plugins/selection_shortcuts.ts new file mode 100644 index 000000000000..d0849d34f040 --- /dev/null +++ b/public/app/features/explore/slate-plugins/selection_shortcuts.ts @@ -0,0 +1,72 @@ +import { Plugin } from '@grafana/slate-react'; +import { Editor as CoreEditor } from 'slate'; + +import { isKeyHotkey } from 'is-hotkey'; + +const isSelectLeftHotkey = isKeyHotkey('shift+left'); +const isSelectRightHotkey = isKeyHotkey('shift+right'); +const isSelectUpHotkey = isKeyHotkey('shift+up'); +const isSelectDownHotkey = isKeyHotkey('shift+down'); +const isSelectLineHotkey = isKeyHotkey('mod+l'); + +const handleSelectVertical = (editor: CoreEditor, direction: 'up' | 'down') => { + const { focusBlock } = editor.value; + const adjacentBlock = + direction === 'up' + ? editor.value.document.getPreviousBlock(focusBlock.key) + : editor.value.document.getNextBlock(focusBlock.key); + + if (!adjacentBlock) { + return true; + } + const adjacentText = adjacentBlock.getFirstText(); + editor + .moveFocusTo(adjacentText.key, Math.min(editor.value.selection.anchor.offset, adjacentText.text.length)) + .focus(); + return true; +}; + +const handleSelectUp = (editor: CoreEditor) => handleSelectVertical(editor, 'up'); + +const handleSelectDown = (editor: CoreEditor) => handleSelectVertical(editor, 'down'); + +// Clears the rest of the line after the caret +export default function SelectionShortcutsPlugin(): Plugin { + return { + onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { + if (isSelectLeftHotkey(event)) { + event.preventDefault(); + if (editor.value.selection.focus.offset > 0) { + editor.moveFocusBackward(1); + } + } else if (isSelectRightHotkey(event)) { + event.preventDefault(); + if (editor.value.selection.focus.offset < editor.value.startText.text.length) { + editor.moveFocusForward(1); + } + } else if (isSelectUpHotkey(event)) { + event.preventDefault(); + handleSelectUp(editor); + } else if (isSelectDownHotkey(event)) { + event.preventDefault(); + handleSelectDown(editor); + } else if (isSelectLineHotkey(event)) { + event.preventDefault(); + const { focusBlock, document } = editor.value; + + editor.moveAnchorToStartOfBlock(); + + const nextBlock = document.getNextBlock(focusBlock.key); + if (nextBlock) { + editor.moveFocusToStartOfNextBlock(); + } else { + editor.moveFocusToEndOfText(); + } + } else { + return next(); + } + + return true; + }, + }; +} diff --git a/public/app/features/explore/slate-plugins/suggestions.tsx b/public/app/features/explore/slate-plugins/suggestions.tsx new file mode 100644 index 000000000000..a3106ff5795e --- /dev/null +++ b/public/app/features/explore/slate-plugins/suggestions.tsx @@ -0,0 +1,313 @@ +import React from 'react'; +import debounce from 'lodash/debounce'; +import sortBy from 'lodash/sortBy'; + +import { Editor as CoreEditor } from 'slate'; +import { Plugin as SlatePlugin } from '@grafana/slate-react'; +import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types'; + +import { QueryField, TypeaheadInput } from '../QueryField'; +import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK'; +import { TypeaheadWithTheme, Typeahead } from '../Typeahead'; + +import { makeFragment } from '@grafana/ui'; + +export const TYPEAHEAD_DEBOUNCE = 100; + +export interface SuggestionsState { + groupedItems: CompletionItemGroup[]; + typeaheadPrefix: string; + typeaheadContext: string; + typeaheadText: string; +} + +let state: SuggestionsState = { + groupedItems: [], + typeaheadPrefix: '', + typeaheadContext: '', + typeaheadText: '', +}; + +export default function SuggestionsPlugin({ + onTypeahead, + cleanText, + onWillApplySuggestion, + syntax, + portalOrigin, + component, +}: { + onTypeahead: (typeahead: TypeaheadInput) => Promise; + cleanText?: (text: string) => string; + onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; + syntax?: string; + portalOrigin: string; + component: QueryField; // Need to attach typeaheadRef here +}): SlatePlugin { + return { + onBlur: (event, editor, next) => { + state = { + ...state, + groupedItems: [], + }; + + return next(); + }, + + onClick: (event, editor, next) => { + state = { + ...state, + groupedItems: [], + }; + + return next(); + }, + + onKeyDown: (event: KeyboardEvent, editor, next) => { + const currentSuggestions = state.groupedItems; + + const hasSuggestions = currentSuggestions.length; + + switch (event.key) { + case 'Escape': { + if (hasSuggestions) { + event.preventDefault(); + + state = { + ...state, + groupedItems: [], + }; + + // Bogus edit to re-render editor + return editor.insertText(''); + } + + break; + } + + case 'ArrowDown': + case 'ArrowUp': + if (hasSuggestions) { + event.preventDefault(); + component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1); + return; + } + + break; + + case 'Enter': + case 'Tab': { + if (hasSuggestions) { + event.preventDefault(); + + component.typeaheadRef.insertSuggestion(); + return handleTypeahead(event, editor, next, onTypeahead, cleanText); + } + + break; + } + + default: { + handleTypeahead(event, editor, next, onTypeahead, cleanText); + break; + } + } + + return next(); + }, + + commands: { + selectSuggestion: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => { + const suggestions = state.groupedItems; + if (!suggestions || !suggestions.length) { + return editor; + } + + // @ts-ignore + return editor.applyTypeahead(suggestion); + }, + + applyTypeahead: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => { + let suggestionText = suggestion.insertText || suggestion.label; + + const preserveSuffix = suggestion.kind === 'function'; + const move = suggestion.move || 0; + + const { typeaheadPrefix, typeaheadText, typeaheadContext } = state; + + if (onWillApplySuggestion) { + suggestionText = onWillApplySuggestion(suggestionText, { + groupedItems: state.groupedItems, + typeaheadContext, + typeaheadPrefix, + typeaheadText, + }); + } + + // Remove the current, incomplete text and replace it with the selected suggestion + const backward = suggestion.deleteBackwards || typeaheadPrefix.length; + const text = cleanText ? cleanText(typeaheadText) : typeaheadText; + const suffixLength = text.length - typeaheadPrefix.length; + const offset = typeaheadText.indexOf(typeaheadPrefix); + const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); + const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; + + // If new-lines, apply suggestion as block + if (suggestionText.match(/\n/)) { + const fragment = makeFragment(suggestionText); + return editor + .deleteBackward(backward) + .deleteForward(forward) + .insertFragment(fragment) + .focus(); + } + + state = { + ...state, + groupedItems: [], + }; + + return editor + .deleteBackward(backward) + .deleteForward(forward) + .insertText(suggestionText) + .moveForward(move) + .focus(); + }, + }, + + renderEditor: (props, editor, next) => { + if (editor.value.selection.isExpanded) { + return next(); + } + + const children = next(); + + return ( + <> + {children} + (component.typeaheadRef = el)} + origin={portalOrigin} + prefix={state.typeaheadPrefix} + isOpen={!!state.groupedItems.length} + groupedItems={state.groupedItems} + //@ts-ignore + onSelectSuggestion={editor.selectSuggestion} + /> + + ); + }, + }; +} + +const handleTypeahead = debounce( + async ( + event: Event, + editor: CoreEditor, + next: () => {}, + onTypeahead?: (typeahead: TypeaheadInput) => Promise, + cleanText?: (text: string) => string + ) => { + if (!onTypeahead) { + return next(); + } + + const { value } = editor; + const { selection } = value; + + // Get decorations associated with the current line + const parentBlock = value.document.getClosestBlock(value.focusBlock.key); + const myOffset = value.selection.start.offset - 1; + const decorations = parentBlock.getDecorations(editor as any); + + const filteredDecorations = decorations + .filter( + decoration => + decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK + ) + .toArray(); + + const labelKeyDec = decorations + .filter( + decoration => + decoration.end.offset === myOffset && + decoration.type === TOKEN_MARK && + decoration.data.get('className').includes('label-key') + ) + .first(); + + const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset); + + const wrapperClasses = filteredDecorations + .map(decoration => decoration.data.get('className')) + .join(' ') + .split(' ') + .filter(className => className.length); + + let text = value.focusText.text; + let prefix = text.slice(0, selection.focus.offset); + + if (filteredDecorations.length) { + text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset); + prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset); + } + + // Label values could have valid characters erased if `cleanText()` is + // blindly applied, which would undesirably interfere with suggestions + const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); + if (labelValueMatch) { + prefix = labelValueMatch[1]; + } else if (cleanText) { + prefix = cleanText(prefix); + } + + const { suggestions, context } = await onTypeahead({ + prefix, + text, + value, + wrapperClasses, + labelKey, + }); + + const filteredSuggestions = suggestions + .map(group => { + if (!group.items) { + return group; + } + + if (prefix) { + // Filter groups based on prefix + if (!group.skipFilter) { + group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); + if (group.prefixMatch) { + group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix)); + } else { + group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix)); + } + } + + // Filter out the already typed value (prefix) unless it inserts custom text + group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); + } + + if (!group.skipSort) { + group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); + } + + return group; + }) + .filter(group => group.items && group.items.length); // Filter out empty groups + + state = { + ...state, + groupedItems: filteredSuggestions, + typeaheadPrefix: prefix, + typeaheadContext: context, + typeaheadText: text, + }; + + // Bogus edit to force re-render + return editor.insertText(''); + }, + TYPEAHEAD_DEBOUNCE +); diff --git a/public/app/features/explore/utils/typeahead.ts b/public/app/features/explore/utils/typeahead.ts index 7de817e45787..e501e2ab60a4 100644 --- a/public/app/features/explore/utils/typeahead.ts +++ b/public/app/features/explore/utils/typeahead.ts @@ -1,14 +1,13 @@ import { GrafanaTheme } from '@grafana/ui'; import { default as calculateSize } from 'calculate-size'; -import { CompletionItemGroup, CompletionItem } from 'app/types'; -import { GROUP_TITLE_KIND } from '../TypeaheadItem'; +import { CompletionItemGroup, CompletionItem, CompletionItemKind } from 'app/types'; export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => { return groupedItems.reduce((all, current) => { const titleItem: CompletionItem = { label: current.label, - kind: GROUP_TITLE_KIND, + kind: CompletionItemKind.GroupTitle, }; return all.concat(titleItem, current.items); }, []); @@ -56,8 +55,7 @@ export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaThem export const calculateListHeight = (itemHeight: number, allItems: CompletionItem[]) => { const numberOfItemsToShow = Math.min(allItems.length, 10); const minHeight = 100; - const itemsInView = allItems.slice(0, numberOfItemsToShow); - const totalHeight = itemsInView.length * itemHeight; + const totalHeight = numberOfItemsToShow * itemHeight; const listHeight = Math.max(totalHeight, minHeight); return listHeight; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 1425eeb32266..2b7af6e32253 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -10,7 +10,7 @@ import jquery from 'jquery'; import prismjs from 'prismjs'; import slate from 'slate'; // @ts-ignore -import slateReact from 'slate-react'; +import slateReact from '@grafana/slate-react'; // @ts-ignore import slatePlain from 'slate-plain-serializer'; import react from 'react'; @@ -91,7 +91,7 @@ exposeToPlugin('rxjs', { // Experimental modules exposeToPlugin('prismjs', prismjs); exposeToPlugin('slate', slate); -exposeToPlugin('slate-react', slateReact); +exposeToPlugin('@grafana/slate-react', slateReact); exposeToPlugin('slate-plain-serializer', slatePlain); exposeToPlugin('react', react); exposeToPlugin('react-dom', reactDom); diff --git a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx b/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx index ab6ef40272c8..98c01ba10573 100644 --- a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx @@ -1,9 +1,7 @@ import _ from 'lodash'; import React from 'react'; -// @ts-ignore -import PluginPrism from 'slate-prism'; -// @ts-ignore -import Prism from 'prismjs'; + +import { SlatePrism } from '@grafana/ui'; // dom also includes Element polyfills import QueryField from 'app/features/explore/QueryField'; @@ -24,7 +22,7 @@ class ElasticsearchQueryField extends React.PureComponent { super(props, context); this.plugins = [ - PluginPrism({ + SlatePrism({ onlyIn: (node: any) => node.type === 'code_block', getSyntax: (node: any) => 'lucene', }), diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx index d3052691acc6..9e8c4a3205c3 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx @@ -1,12 +1,13 @@ import _ from 'lodash'; -// @ts-ignore import Plain from 'slate-plain-serializer'; import QueryField from './query_field'; import debounce from 'lodash/debounce'; import { DOMUtil } from '@grafana/ui'; +import { Editor as SlateEditor } from 'slate'; import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto'; +import { CompletionItem } from 'app/types'; // import '../sass/editor.base.scss'; const TYPEAHEAD_DELAY = 100; @@ -63,7 +64,7 @@ export default class KustoQueryField extends QueryField { this.fetchSchema(); } - onTypeahead = (force?: boolean) => { + onTypeahead = (force = false) => { const selection = window.getSelection(); if (selection.anchorNode) { const wrapperNode = selection.anchorNode.parentElement; @@ -196,15 +197,15 @@ export default class KustoQueryField extends QueryField { } }; - applyTypeahead(change: any, suggestion: { text: any; type: string; deleteBackwards: any }) { + applyTypeahead = (editor: SlateEditor, suggestion: CompletionItem): SlateEditor => { const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state; - let suggestionText = suggestion.text || suggestion; + let suggestionText = suggestion.label; const move = 0; // Modify suggestion based on context const nextChar = DOMUtil.getNextCharacter(); - if (suggestion.type === 'function') { + if (suggestion.kind === 'function') { if (!nextChar || nextChar !== '(') { suggestionText += '('; } @@ -228,13 +229,13 @@ export default class KustoQueryField extends QueryField { const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); const forward = midWord ? suffixLength + offset : 0; - return change + return editor .deleteBackward(backward) .deleteForward(forward) .insertText(suggestionText) - .move(move) + .moveForward(move) .focus(); - } + }; // private _getFieldsSuggestions(): SuggestionGroup[] { // return [ diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx index 42b2f1e858d3..8f81da9a94a3 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx @@ -7,14 +7,13 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; import Typeahead from './typeahead'; import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv'; -import { Block, Document, Text, Value } from 'slate'; -// @ts-ignore -import { Editor } from 'slate-react'; -// @ts-ignore +import { Block, Document, Text, Value, Editor as CoreEditor } from 'slate'; +import { Editor } from '@grafana/slate-react'; import Plain from 'slate-plain-serializer'; import ReactDOM from 'react-dom'; import React from 'react'; import _ from 'lodash'; +import { CompletionItem } from 'app/types'; function flattenSuggestions(s: any) { return s ? s.reduce((acc: any, g: any) => acc.concat(g.items), []) : []; @@ -98,7 +97,7 @@ class QueryField extends React.Component { this.updateMenu(); } - onChange = ({ value }: any) => { + onChange = ({ value }: { value: Value }) => { const changed = value.document !== this.state.value.document; this.setState({ value }, () => { if (changed) { @@ -124,14 +123,15 @@ class QueryField extends React.Component { } }; - onKeyDown = (event: any, change: any) => { + onKeyDown = (event: Event, editor: CoreEditor, next: Function) => { const { typeaheadIndex, suggestions } = this.state; + const keyboardEvent = event as KeyboardEvent; - switch (event.key) { + switch (keyboardEvent.key) { case 'Escape': { if (this.menuEl) { - event.preventDefault(); - event.stopPropagation(); + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); this.resetTypeahead(); return true; } @@ -139,8 +139,8 @@ class QueryField extends React.Component { } case ' ': { - if (event.ctrlKey) { - event.preventDefault(); + if (keyboardEvent.ctrlKey) { + keyboardEvent.preventDefault(); this.onTypeahead(true); return true; } @@ -151,18 +151,12 @@ class QueryField extends React.Component { case 'Enter': { if (this.menuEl) { // Dont blur input - event.preventDefault(); + keyboardEvent.preventDefault(); if (!suggestions || suggestions.length === 0) { - return undefined; + return next(); } - // Get the currently selected suggestion - const flattenedSuggestions = flattenSuggestions(suggestions); - const selected = Math.abs(typeaheadIndex); - const selectedIndex = selected % flattenedSuggestions.length || 0; - const suggestion = flattenedSuggestions[selectedIndex]; - - this.applyTypeahead(change, suggestion); + this.applyTypeahead(); return true; } break; @@ -171,7 +165,7 @@ class QueryField extends React.Component { case 'ArrowDown': { if (this.menuEl) { // Select next suggestion - event.preventDefault(); + keyboardEvent.preventDefault(); this.setState({ typeaheadIndex: typeaheadIndex + 1 }); } break; @@ -180,7 +174,7 @@ class QueryField extends React.Component { case 'ArrowUp': { if (this.menuEl) { // Select previous suggestion - event.preventDefault(); + keyboardEvent.preventDefault(); this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); } break; @@ -191,16 +185,16 @@ class QueryField extends React.Component { break; } } - return undefined; + return next(); }; - onTypeahead = (change?: boolean, item?: any) => { - return change || this.state.value.change(); + onTypeahead = (change = false, item?: any): boolean | void => { + return change; }; - applyTypeahead(change?: boolean, suggestion?: any): { value: object } { - return { value: {} }; - } + applyTypeahead = (editor?: CoreEditor, suggestion?: CompletionItem): { value: Value } => { + return { value: new Value() }; + }; resetTypeahead = () => { this.setState({ @@ -245,15 +239,8 @@ class QueryField extends React.Component { return; } - // Get the currently selected suggestion - const flattenedSuggestions = flattenSuggestions(suggestions); - const suggestion: any = _.find( - flattenedSuggestions, - suggestion => suggestion.display === item || suggestion.text === item - ); - // Manually triggering change - const change = this.applyTypeahead(this.state.value.change(), suggestion); + const change = this.applyTypeahead(); this.onChange(change); }; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx index de7f31ac7f8f..e90aff765be7 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent } from 'react'; import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm'; import { useLokiSyntax } from './useLokiSyntax'; +import LokiLanguageProvider from '../language_provider'; export const LokiQueryField: FunctionComponent = ({ datasource, @@ -8,7 +9,7 @@ export const LokiQueryField: FunctionComponent = ({ ...otherProps }) => { const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax( - datasource.languageProvider, + datasource.languageProvider as LokiLanguageProvider, datasourceStatus, otherProps.absoluteRange ); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx index 57b0b6987f06..f343bb5eab5f 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx @@ -2,18 +2,24 @@ import React from 'react'; // @ts-ignore import Cascader from 'rc-cascader'; -// @ts-ignore -import PluginPrism from 'slate-prism'; + +import { SlatePrism } from '@grafana/ui'; + // Components -import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; +import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField'; // Utils & Services // dom also includes Element polyfills import BracesPlugin from 'app/features/explore/slate-plugins/braces'; +import { Plugin, Node } from 'slate'; + // Types import { LokiQuery } from '../types'; -import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; +import { TypeaheadOutput } from 'app/types/explore'; import { DataSourceApi, ExploreQueryFieldProps, DataSourceStatus, DOMUtil } from '@grafana/ui'; import { AbsoluteTimeRange } from '@grafana/data'; +import { Grammar } from 'prismjs'; +import LokiLanguageProvider, { LokiHistoryItem } from '../language_provider'; +import { SuggestionsState } from 'app/features/explore/slate-plugins/suggestions'; function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DataSourceStatus) { if (datasourceStatus === DataSourceStatus.Disconnected) { @@ -28,7 +34,7 @@ function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceSta return 'Log labels'; } -function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string { +function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { // Modify suggestion based on context switch (typeaheadContext) { case 'context-labels': { @@ -63,17 +69,17 @@ export interface CascaderOption { } export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps, LokiQuery> { - history: HistoryItem[]; - syntax: any; + history: LokiHistoryItem[]; + syntax: Grammar; logLabelOptions: any[]; - syntaxLoaded: any; + syntaxLoaded: boolean; absoluteRange: AbsoluteTimeRange; onLoadOptions: (selectedOptions: CascaderOption[]) => void; onLabelsRefresh?: () => void; } export class LokiQueryFieldForm extends React.PureComponent { - plugins: any[]; + plugins: Plugin[]; modifiedSearch: string; modifiedQuery: string; @@ -82,9 +88,9 @@ export class LokiQueryFieldForm extends React.PureComponent node.type === 'code_block', - getSyntax: (node: any) => 'promql', + SlatePrism({ + onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block', + getSyntax: (node: Node) => 'promql', }), ]; } @@ -115,27 +121,23 @@ export class LokiQueryFieldForm extends React.PureComponent { + onTypeahead = async (typeahead: TypeaheadInput): Promise => { const { datasource } = this.props; + if (!datasource.languageProvider) { return { suggestions: [] }; } + const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider; const { history, absoluteRange } = this.props; - const { prefix, text, value, wrapperNode } = typeahead; - - // Get DOM-dependent context - const wrapperClasses = Array.from(wrapperNode.classList); - const labelKeyNode = DOMUtil.getPreviousCousin(wrapperNode, '.attr-name'); - const labelKey = labelKeyNode && labelKeyNode.textContent; - const nextChar = DOMUtil.getNextCharacter(); + const { prefix, text, value, wrapperClasses, labelKey } = typeahead; - const result = datasource.languageProvider.provideCompletionItems( + const result = await lokiLanguageProvider.provideCompletionItems( { text, value, prefix, wrapperClasses, labelKey }, { history, absoluteRange } ); - console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); + //console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); return result; }; @@ -151,7 +153,8 @@ export class LokiQueryFieldForm extends React.PureComponent 0; const chooserText = getChooserText(syntaxLoaded, hasLogLabels, datasourceStatus); const buttonDisabled = !syntaxLoaded || datasourceStatus === DataSourceStatus.Disconnected; diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts index 62de5c156adc..07c98cc476ce 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts +++ b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts @@ -3,6 +3,7 @@ import { DataSourceStatus } from '@grafana/ui/src/types/datasource'; import { AbsoluteTimeRange } from '@grafana/data'; import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; + import { useLokiSyntax } from './useLokiSyntax'; import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; import { makeMockLokiDatasource } from '../mocks'; diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.ts index 7faa5a6fb247..f4ae3652e4de 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts +++ b/public/app/plugins/datasource/loki/components/useLokiSyntax.ts @@ -1,5 +1,4 @@ import { useState, useEffect } from 'react'; -// @ts-ignore import Prism from 'prismjs'; import { DataSourceStatus } from '@grafana/ui/src/types/datasource'; import { AbsoluteTimeRange } from '@grafana/data'; diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts index 4f0ac9324aa2..0b8caa16f0ad 100644 --- a/public/app/plugins/datasource/loki/language_provider.test.ts +++ b/public/app/plugins/datasource/loki/language_provider.test.ts @@ -1,13 +1,14 @@ -// @ts-ignore import Plain from 'slate-plain-serializer'; +import { Editor as SlateEditor } from 'slate'; import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider'; import { AbsoluteTimeRange } from '@grafana/data'; import { advanceTo, clear, advanceBy } from 'jest-date-mock'; import { beforeEach } from 'test/lib/common'; -import { DataSourceApi } from '@grafana/ui'; + import { TypeaheadInput } from '../../../types'; import { makeMockLokiDatasource } from './mocks'; +import LokiDatasource from './datasource'; describe('Language completion provider', () => { const datasource = makeMockLokiDatasource({}); @@ -18,16 +19,16 @@ describe('Language completion provider', () => { }; describe('empty query suggestions', () => { - it('returns no suggestions on empty context', () => { + it('returns no suggestions on empty context', async () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(0); }); - it('returns default suggestions with history on empty context when history was provided', () => { + it('returns default suggestions with history on empty context when history was provided', async () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); const history: LokiHistoryItem[] = [ @@ -36,12 +37,12 @@ describe('Language completion provider', () => { ts: 1, }, ]; - const result = instance.provideCompletionItems( + const result = await instance.provideCompletionItems( { text: '', prefix: '', value, wrapperClasses: [] }, { history, absoluteRange: rangeMock } ); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); + expect(result.suggestions).toMatchObject([ { label: 'History', @@ -54,7 +55,7 @@ describe('Language completion provider', () => { ]); }); - it('returns no suggestions within regexp', () => { + it('returns no suggestions within regexp', async () => { const instance = new LanguageProvider(datasource); const input = createTypeaheadInput('{} ()', '', undefined, 4, []); const history: LokiHistoryItem[] = [ @@ -63,18 +64,28 @@ describe('Language completion provider', () => { ts: 1, }, ]; - const result = instance.provideCompletionItems(input, { history }); + const result = await instance.provideCompletionItems(input, { history }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(0); }); }); describe('label suggestions', () => { - it('returns default label suggestions on label context', () => { + it('returns default label suggestions on label context', async () => { const instance = new LanguageProvider(datasource); - const input = createTypeaheadInput('{}', ''); - const result = instance.provideCompletionItems(input, { absoluteRange: rangeMock }); + const value = Plain.deserialize('{}'); + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(1).value; + const result = await instance.provideCompletionItems( + { + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }, + { absoluteRange: rangeMock } + ); expect(result.context).toBe('context-labels'); expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]); }); @@ -83,7 +94,7 @@ describe('Language completion provider', () => { const datasource = makeMockLokiDatasource({ label1: [], label2: [] }); const provider = await getLanguageProvider(datasource); const input = createTypeaheadInput('{}', ''); - const result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock }); expect(result.context).toBe('context-labels'); expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }, { label: 'label2' }], label: 'Labels' }]); }); @@ -92,11 +103,9 @@ describe('Language completion provider', () => { const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] }); const provider = await getLanguageProvider(datasource); const input = createTypeaheadInput('{label1=}', '=', 'label1'); - let result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); - // The values for label are loaded adhoc and there is a promise returned that we have to wait for - expect(result.refresher).toBeDefined(); - await result.refresher; - result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + let result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + + result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock }); expect(result.context).toBe('context-label-values'); expect(result.suggestions).toEqual([ { items: [{ label: 'label1_val1' }, { label: 'label1_val2' }], label: 'Label values for "label1"' }, @@ -201,7 +210,7 @@ describe('Labels refresh', () => { }); }); -async function getLanguageProvider(datasource: DataSourceApi) { +async function getLanguageProvider(datasource: LokiDatasource) { const instance = new LanguageProvider(datasource); instance.initialRange = { from: Date.now() - 10000, @@ -224,10 +233,8 @@ function createTypeaheadInput( wrapperClasses?: string[] ): TypeaheadInput { const deserialized = Plain.deserialize(value); - const range = deserialized.selection.merge({ - anchorOffset: anchorOffset || 1, - }); - const valueWithSelection = deserialized.change().select(range).value; + const range = deserialized.selection.setAnchor(deserialized.selection.anchor.setOffset(anchorOffset || 1)); + const valueWithSelection = deserialized.setSelection(range); return { text, prefix: '', diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 44ba031255f0..ac5f877f8d43 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -6,18 +6,12 @@ import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasour import syntax from './syntax'; // Types -import { - CompletionItem, - CompletionItemGroup, - LanguageProvider, - TypeaheadInput, - TypeaheadOutput, - HistoryItem, -} from 'app/types/explore'; +import { CompletionItem, LanguageProvider, TypeaheadInput, TypeaheadOutput, HistoryItem } from 'app/types/explore'; import { LokiQuery } from './types'; import { dateTime, AbsoluteTimeRange } from '@grafana/data'; import { PromQuery } from '../prometheus/types'; -import { DataSourceApi } from '@grafana/ui'; + +import LokiDatasource from './datasource'; const DEFAULT_KEYS = ['job', 'namespace']; const EMPTY_SELECTOR = '{}'; @@ -59,8 +53,9 @@ export default class LokiLanguageProvider extends LanguageProvider { logLabelFetchTs?: number; started: boolean; initialRange: AbsoluteTimeRange; + datasource: LokiDatasource; - constructor(datasource: DataSourceApi, initialValues?: any) { + constructor(datasource: LokiDatasource, initialValues?: any) { super(); this.datasource = datasource; @@ -69,6 +64,7 @@ export default class LokiLanguageProvider extends LanguageProvider { Object.assign(this, initialValues); } + // Strip syntax chars cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); @@ -111,14 +107,14 @@ export default class LokiLanguageProvider extends LanguageProvider { * @param context.absoluteRange Required in case we are doing getLabelCompletionItems * @param context.history Optional used only in getEmptyCompletionItems */ - provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): TypeaheadOutput { + async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise { const { wrapperClasses, value } = input; // Local text properties const empty = value.document.text.length === 0; // Determine candidates by CSS context if (_.includes(wrapperClasses, 'context-labels')) { // Suggestions for {|} and {foo=|} - return this.getLabelCompletionItems(input, context); + return await this.getLabelCompletionItems(input, context); } else if (empty) { return this.getEmptyCompletionItems(context || {}); } @@ -130,7 +126,7 @@ export default class LokiLanguageProvider extends LanguageProvider { getEmptyCompletionItems(context: any): TypeaheadOutput { const { history } = context; - const suggestions: CompletionItemGroup[] = []; + const suggestions = []; if (history && history.length > 0) { const historyItems = _.chain(history) @@ -153,15 +149,14 @@ export default class LokiLanguageProvider extends LanguageProvider { return { suggestions }; } - getLabelCompletionItems( + async getLabelCompletionItems( { text, wrapperClasses, labelKey, value }: TypeaheadInput, { absoluteRange }: any - ): TypeaheadOutput { + ): Promise { let context: string; - let refresher: Promise = null; - const suggestions: CompletionItemGroup[] = []; + const suggestions = []; const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; + const cursorOffset: number = value.selection.anchor.offset; // Use EMPTY_SELECTOR until series API is implemented for facetting const selector = EMPTY_SELECTOR; @@ -171,19 +166,20 @@ export default class LokiLanguageProvider extends LanguageProvider { } catch {} const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { + if ((text && text.match(/^!?=~?/)) || wrapperClasses.includes('attr-value')) { // Label values if (labelKey && this.labelValues[selector]) { - const labelValues = this.labelValues[selector][labelKey]; - if (labelValues) { - context = 'context-label-values'; - suggestions.push({ - label: `Label values for "${labelKey}"`, - items: labelValues.map(wrapLabel), - }); - } else { - refresher = this.fetchLabelValues(labelKey, absoluteRange); + let labelValues = this.labelValues[selector][labelKey]; + if (!labelValues) { + await this.fetchLabelValues(labelKey, absoluteRange); + labelValues = this.labelValues[selector][labelKey]; } + + context = 'context-label-values'; + suggestions.push({ + label: `Label values for "${labelKey}"`, + items: labelValues.map(wrapLabel), + }); } } else { // Label keys @@ -197,7 +193,7 @@ export default class LokiLanguageProvider extends LanguageProvider { } } - return { context, refresher, suggestions }; + return { context, suggestions }; } async importQueries(queries: LokiQuery[], datasourceType: string): Promise { diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts index 49c2de7dcc01..7e91c51c1056 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -1,6 +1,6 @@ -import { DataSourceApi } from '@grafana/ui'; +import LokiDatasource from './datasource'; -export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): DataSourceApi { +export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): LokiDatasource { const labels = Object.keys(labelsAndValues); return { metadataRequest: (url: string) => { diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts index 0748b4e5ffd2..2e83723a8156 100644 --- a/public/app/plugins/datasource/loki/syntax.ts +++ b/public/app/plugins/datasource/loki/syntax.ts @@ -1,6 +1,8 @@ +import { Grammar } from 'prismjs'; + /* tslint:disable max-line-length */ -const tokenizer = { +const tokenizer: Grammar = { comment: { pattern: /(^|[^\n])#.*/, lookbehind: true, diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index 99eef38bde3a..963a2d79a302 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -2,20 +2,22 @@ import _ from 'lodash'; import React from 'react'; // @ts-ignore import Cascader from 'rc-cascader'; -// @ts-ignore -import PluginPrism from 'slate-prism'; -// @ts-ignore + +import { SlatePrism } from '@grafana/ui'; + import Prism from 'prismjs'; import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; // dom also includes Element polyfills import BracesPlugin from 'app/features/explore/slate-plugins/braces'; -import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; +import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField'; import { PromQuery, PromContext, PromOptions } from '../types'; import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; import { ExploreQueryFieldProps, DataSourceStatus, QueryHint, DOMUtil } from '@grafana/ui'; import { isDataFrame, toLegacyResponseData } from '@grafana/data'; import { PrometheusDatasource } from '../datasource'; +import PromQlLanguageProvider from '../language_provider'; +import { SuggestionsState } from 'app/features/explore/slate-plugins/suggestions'; const HISTOGRAM_GROUP = '__histograms__'; const METRIC_MARK = 'metric'; @@ -67,7 +69,7 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad return [...options, ...metricsOptions]; } -export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string { +export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { // Modify suggestion based on context switch (typeaheadContext) { case 'context-labels': { @@ -102,7 +104,7 @@ interface CascaderOption { } interface PromQueryFieldProps extends ExploreQueryFieldProps { - history: HistoryItem[]; + history: Array>; } interface PromQueryFieldState { @@ -113,7 +115,7 @@ interface PromQueryFieldState { class PromQueryField extends React.PureComponent { plugins: any[]; - languageProvider: any; + languageProvider: PromQlLanguageProvider; languageProviderInitializationPromise: CancelablePromise; constructor(props: PromQueryFieldProps, context: React.Context) { @@ -125,7 +127,7 @@ class PromQueryField extends React.PureComponent node.type === 'code_block', getSyntax: (node: any) => 'promql', }), @@ -252,7 +254,7 @@ class PromQueryField extends React.PureComponent { + onTypeahead = async (typeahead: TypeaheadInput): Promise => { if (!this.languageProvider) { return { suggestions: [] }; } const { history } = this.props; - const { prefix, text, value, wrapperNode } = typeahead; - - // Get DOM-dependent context - const wrapperClasses = Array.from(wrapperNode.classList); - const labelKeyNode = DOMUtil.getPreviousCousin(wrapperNode, '.attr-name'); - const labelKey = labelKeyNode && labelKeyNode.textContent; - const nextChar = DOMUtil.getNextCharacter(); + const { prefix, text, value, wrapperClasses, labelKey } = typeahead; - const result = this.languageProvider.provideCompletionItems( + const result = await this.languageProvider.provideCompletionItems( { text, value, prefix, wrapperClasses, labelKey }, { history } ); - console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); + // console.log('handleTypeahead', wrapperClasses, text, prefix, labelKey, result.context); return result; }; diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 86cdb57f02a6..018b32e49814 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -1,23 +1,28 @@ import _ from 'lodash'; +import { dateTime } from '@grafana/data'; + import { CompletionItem, CompletionItemGroup, LanguageProvider, TypeaheadInput, TypeaheadOutput, + HistoryItem, } from 'app/types/explore'; import { parseSelector, processLabels, processHistogramLabels } from './language_utils'; import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql'; -import { dateTime } from '@grafana/data'; + +import { PrometheusDatasource } from './datasource'; +import { PromQuery } from './types'; const DEFAULT_KEYS = ['job', 'instance']; const EMPTY_SELECTOR = '{}'; const HISTORY_ITEM_COUNT = 5; const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h -const wrapLabel = (label: string) => ({ label }); +const wrapLabel = (label: string): CompletionItem => ({ label }); const setFunctionKind = (suggestion: CompletionItem): CompletionItem => { suggestion.kind = 'function'; @@ -30,10 +35,12 @@ export function addHistoryMetadata(item: CompletionItem, history: any[]): Comple const count = historyForItem.length; const recent = historyForItem[0]; let hint = `Queried ${count} times in the last 24h.`; + if (recent) { const lastQueried = dateTime(recent.ts).fromNow(); hint = `${hint} Last queried ${lastQueried}.`; } + return { ...item, documentation: hint, @@ -47,8 +54,9 @@ export default class PromQlLanguageProvider extends LanguageProvider { labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] metrics?: string[]; startTask: Promise; + datasource: PrometheusDatasource; - constructor(datasource: any, initialValues?: any) { + constructor(datasource: PrometheusDatasource, initialValues?: any) { super(); this.datasource = datasource; @@ -60,10 +68,11 @@ export default class PromQlLanguageProvider extends LanguageProvider { Object.assign(this, initialValues); } + // Strip syntax chars cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); - getSyntax() { + get syntax() { return PromqlSyntax; } @@ -106,39 +115,46 @@ export default class PromQlLanguageProvider extends LanguageProvider { } }; - // Keep this DOM-free for testing - provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput { + provideCompletionItems = async ( + { prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput, + context: { history: Array> } = { history: [] } + ): Promise => { // Local text properties const empty = value.document.text.length === 0; - const selectedLines = value.document.getTextsAtRangeAsArray(value.selection); - const currentLine = selectedLines.length === 1 ? selectedLines[0] : null; - const nextCharacter = currentLine ? currentLine.text[value.selection.anchorOffset] : null; + const selectedLines = value.document.getTextsAtRange(value.selection); + const currentLine = selectedLines.size === 1 ? selectedLines.first().getText() : null; + + const nextCharacter = currentLine ? currentLine[value.selection.anchor.offset] : null; // Syntax spans have 3 classes by default. More indicate a recognized token const tokenRecognized = wrapperClasses.length > 3; // Non-empty prefix, but not inside known token const prefixUnrecognized = prefix && !tokenRecognized; + // Prevent suggestions in `function(|suffix)` const noSuffix = !nextCharacter || nextCharacter === ')'; - // Empty prefix is safe if it does not immediately folllow a complete expression and has no text after it + + // Empty prefix is safe if it does not immediately follow a complete expression and has no text after it const safeEmptyPrefix = prefix === '' && !text.match(/^[\]})\s]+$/) && noSuffix; + // About to type next operand if preceded by binary operator - const isNextOperand = text.match(/[+\-*/^%]/); + const operatorsPattern = /[+\-*/^%]/; + const isNextOperand = text.match(operatorsPattern); // Determine candidates by CSS context - if (_.includes(wrapperClasses, 'context-range')) { + if (wrapperClasses.includes('context-range')) { // Suggestions for metric[|] return this.getRangeCompletionItems(); - } else if (_.includes(wrapperClasses, 'context-labels')) { + } else if (wrapperClasses.includes('context-labels')) { // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} - return this.getLabelCompletionItems.apply(this, arguments); - } else if (_.includes(wrapperClasses, 'context-aggregation')) { + return this.getLabelCompletionItems({ prefix, text, value, labelKey, wrapperClasses }); + } else if (wrapperClasses.includes('context-aggregation')) { // Suggestions for sum(metric) by (|) - return this.getAggregationCompletionItems.apply(this, arguments); + return this.getAggregationCompletionItems({ prefix, text, value, labelKey, wrapperClasses }); } else if (empty) { // Suggestions for empty query field - return this.getEmptyCompletionItems(context || {}); - } else if (prefixUnrecognized || safeEmptyPrefix || isNextOperand) { + return this.getEmptyCompletionItems(context); + } else if ((prefixUnrecognized && noSuffix) || safeEmptyPrefix || isNextOperand) { // Show term suggestions in a couple of scenarios return this.getTermCompletionItems(); } @@ -146,20 +162,20 @@ export default class PromQlLanguageProvider extends LanguageProvider { return { suggestions: [], }; - } + }; - getEmptyCompletionItems(context: any): TypeaheadOutput { + getEmptyCompletionItems = (context: { history: Array> }): TypeaheadOutput => { const { history } = context; - let suggestions: CompletionItemGroup[] = []; + const suggestions = []; - if (history && history.length > 0) { + if (history && history.length) { const historyItems = _.chain(history) - .map((h: any) => h.query.expr) + .map(h => h.query.expr) .filter() .uniq() .take(HISTORY_ITEM_COUNT) .map(wrapLabel) - .map((item: CompletionItem) => addHistoryMetadata(item, history)) + .map(item => addHistoryMetadata(item, history)) .value(); suggestions.push({ @@ -171,14 +187,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { } const termCompletionItems = this.getTermCompletionItems(); - suggestions = [...suggestions, ...termCompletionItems.suggestions]; + suggestions.push(...termCompletionItems.suggestions); return { suggestions }; - } + }; - getTermCompletionItems(): TypeaheadOutput { + getTermCompletionItems = (): TypeaheadOutput => { const { metrics } = this; - const suggestions: CompletionItemGroup[] = []; + const suggestions = []; suggestions.push({ prefixMatch: true, @@ -186,14 +202,15 @@ export default class PromQlLanguageProvider extends LanguageProvider { items: FUNCTIONS.map(setFunctionKind), }); - if (metrics && metrics.length > 0) { + if (metrics && metrics.length) { suggestions.push({ label: 'Metrics', items: metrics.map(wrapLabel), }); } + return { suggestions }; - } + }; getRangeCompletionItems(): TypeaheadOutput { return { @@ -219,21 +236,21 @@ export default class PromQlLanguageProvider extends LanguageProvider { ); } - getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput { + getAggregationCompletionItems = ({ value }: TypeaheadInput): TypeaheadOutput => { const refresher: Promise = null; const suggestions: CompletionItemGroup[] = []; // Stitch all query lines together to support multi-line queries let queryOffset; - const queryText = value.document.getBlocks().reduce((text: string, block: any) => { + const queryText = value.document.getBlocks().reduce((text: string, block) => { const blockText = block.getText(); if (value.anchorBlock.key === block.key) { // Newline characters are not accounted for but this is irrelevant // for the purpose of extracting the selector string - queryOffset = value.anchorOffset + text.length; + queryOffset = value.selection.anchor.offset + text.length; } - text += blockText; - return text; + + return text + blockText; }, ''); // Try search for selector part on the left-hand side, such as `sum (m) by (l)` @@ -259,10 +276,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { return result; } - let selectorString = queryText.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); - // Range vector syntax not accounted for by subsequent parse so discard it if present - selectorString = selectorString.replace(/\[[^\]]+\]$/, ''); + const selectorString = queryText + .slice(openParensSelectorIndex + 1, closeParensSelectorIndex) + .replace(/\[[^\]]+\]$/, ''); const selector = parseSelector(selectorString, selectorString.length - 2).selector; @@ -274,14 +291,16 @@ export default class PromQlLanguageProvider extends LanguageProvider { } return result; - } + }; - getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput { - let context: string; - let refresher: Promise = null; - const suggestions: CompletionItemGroup[] = []; + getLabelCompletionItems = async ({ + text, + wrapperClasses, + labelKey, + value, + }: TypeaheadInput): Promise => { const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; + const cursorOffset = value.selection.anchor.offset; // Get normalized selector let selector; @@ -292,10 +311,23 @@ export default class PromQlLanguageProvider extends LanguageProvider { } catch { selector = EMPTY_SELECTOR; } - const containsMetric = selector.indexOf('__name__=') > -1; + + const containsMetric = selector.includes('__name__='); const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { + // Query labels for selector + if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) { + if (selector === EMPTY_SELECTOR) { + // Query label values for default labels + await Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); + } else { + await this.fetchSeriesLabels(selector, !containsMetric); + } + } + + const suggestions = []; + let context: string; + if ((text && text.match(/^!?=~?/)) || wrapperClasses.includes('attr-value')) { // Label values if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) { const labelValues = this.labelValues[selector][labelKey]; @@ -308,27 +340,20 @@ export default class PromQlLanguageProvider extends LanguageProvider { } else { // Label keys const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); + if (labelKeys) { const possibleKeys = _.difference(labelKeys, existingKeys); - if (possibleKeys.length > 0) { + if (possibleKeys.length) { context = 'context-labels'; - suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); + const newItems = possibleKeys.map(key => ({ label: key })); + const newSuggestion: CompletionItemGroup = { label: `Labels`, items: newItems }; + suggestions.push(newSuggestion); } } } - // Query labels for selector - if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) { - if (selector === EMPTY_SELECTOR) { - // Query label values for default labels - refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); - } else { - refresher = this.fetchSeriesLabels(selector, !containsMetric); - } - } - - return { context, refresher, suggestions }; - } + return { context, suggestions }; + }; fetchLabelValues = async (key: string) => { try { diff --git a/public/app/plugins/datasource/prometheus/language_utils.ts b/public/app/plugins/datasource/prometheus/language_utils.ts index f10d9eed4224..535f86fc8bcd 100644 --- a/public/app/plugins/datasource/prometheus/language_utils.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.ts @@ -16,13 +16,13 @@ export const processHistogramLabels = (labels: string[]) => { return { values: { __name__: result } }; }; -export function processLabels(labels: any, withName = false) { +export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) { const values: { [key: string]: string[] } = {}; - labels.forEach((l: any) => { + labels.forEach(l => { const { __name__, ...rest } = l; if (withName) { values['__name__'] = values['__name__'] || []; - if (values['__name__'].indexOf(__name__) === -1) { + if (!values['__name__'].includes(__name__)) { values['__name__'].push(__name__); } } @@ -31,7 +31,7 @@ export function processLabels(labels: any, withName = false) { if (!values[key]) { values[key] = []; } - if (values[key].indexOf(rest[key]) === -1) { + if (!values[key].includes(rest[key])) { values[key].push(rest[key]); } }); diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts index fe1679eb94b5..9171201fc377 100644 --- a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts @@ -1,21 +1,22 @@ -// @ts-ignore import Plain from 'slate-plain-serializer'; - +import { Editor as SlateEditor } from 'slate'; import LanguageProvider from '../language_provider'; +import { PrometheusDatasource } from '../datasource'; +import { HistoryItem } from 'app/types'; +import { PromQuery } from '../types'; describe('Language completion provider', () => { - const datasource = { + const datasource: PrometheusDatasource = ({ metadataRequest: () => ({ data: { data: [] as any[] } }), getTimeRange: () => ({ start: 0, end: 1 }), - }; + } as any) as PrometheusDatasource; describe('empty query suggestions', () => { - it('returns default suggestions on emtpty context', () => { + it('returns default suggestions on empty context', async () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -23,12 +24,11 @@ describe('Language completion provider', () => { ]); }); - it('returns default suggestions with metrics on emtpty context when metrics were provided', () => { + it('returns default suggestions with metrics on empty context when metrics were provided', async () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const value = Plain.deserialize(''); - const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -39,17 +39,21 @@ describe('Language completion provider', () => { ]); }); - it('returns default suggestions with history on emtpty context when history was provided', () => { + it('returns default suggestions with history on empty context when history was provided', async () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const history = [ + const history: Array> = [ { + ts: 0, query: { refId: '1', expr: 'metric' }, }, ]; - const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history }); + const result = await instance.provideCompletionItems( + { text: '', prefix: '', value, wrapperClasses: [] }, + { history } + ); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); + expect(result.suggestions).toMatchObject([ { label: 'History', @@ -67,17 +71,16 @@ describe('Language completion provider', () => { }); describe('range suggestions', () => { - it('returns range suggestions in range context', () => { + it('returns range suggestions in range context', async () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize('1'); - const result = instance.provideCompletionItems({ + const result = await instance.provideCompletionItems({ text: '1', prefix: '1', value, wrapperClasses: ['context-range'], }); expect(result.context).toBe('context-range'); - expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { items: [ @@ -96,12 +99,12 @@ describe('Language completion provider', () => { }); describe('metric suggestions', () => { - it('returns metrics and function suggestions in an unknown context', () => { + it('returns metrics and function suggestions in an unknown context', async () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); - const value = Plain.deserialize('a'); - const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] }); + let value = Plain.deserialize('a'); + value = value.setSelection({ anchor: { offset: 1 }, focus: { offset: 1 } }); + const result = await instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -112,12 +115,11 @@ describe('Language completion provider', () => { ]); }); - it('returns metrics and function suggestions after a binary operator', () => { + it('returns metrics and function suggestions after a binary operator', async () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const value = Plain.deserialize('*'); - const result = instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] }); + const result = await instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -128,34 +130,30 @@ describe('Language completion provider', () => { ]); }); - it('returns no suggestions at the beginning of a non-empty function', () => { + it('returns no suggestions at the beginning of a non-empty function', async () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const value = Plain.deserialize('sum(up)'); - const range = value.selection.merge({ - anchorOffset: 4, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + + const valueWithSelection = ed.moveForward(4).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', value: valueWithSelection, wrapperClasses: [], }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); expect(result.suggestions.length).toEqual(0); }); }); describe('label suggestions', () => { - it('returns default label suggestions on label context and no metric', () => { + it('returns default label suggestions on label context and no metric', async () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize('{}'); - const range = value.selection.merge({ - anchorOffset: 1, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(1).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], @@ -165,14 +163,16 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); }); - it('returns label suggestions on label context and metric', () => { - const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); + it('returns label suggestions on label context and metric', async () => { + const datasources: PrometheusDatasource = ({ + metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }), + getTimeRange: () => ({ start: 0, end: 1 }), + } as any) as PrometheusDatasource; + const instance = new LanguageProvider(datasources, { labelKeys: { '{__name__="metric"}': ['bar'] } }); const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(7).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], @@ -182,16 +182,32 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label suggestions on label context but leaves out labels that already exist', () => { - const instance = new LanguageProvider(datasource, { - labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }, - }); - const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); - const range = value.selection.merge({ - anchorOffset: 36, + it('returns label suggestions on label context but leaves out labels that already exist', async () => { + const datasources: PrometheusDatasource = ({ + metadataRequest: () => ({ + data: { + data: [ + { + __name__: 'metric', + bar: 'asdasd', + job1: 'dsadsads', + job2: 'fsfsdfds', + job3: 'dsadsad', + }, + ], + }, + }), + getTimeRange: () => ({ start: 0, end: 1 }), + } as any) as PrometheusDatasource; + const instance = new LanguageProvider(datasources, { + labelKeys: { + '{job1="foo",job2!="foo",job3=~"foo",__name__="metric"}': ['bar', 'job1', 'job2', 'job3', '__name__'], + }, }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}'); + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(54).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], @@ -201,15 +217,15 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label value suggestions inside a label value context after a negated matching operator', () => { + it('returns label value suggestions inside a label value context after a negated matching operator', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{}': ['label'] }, labelValues: { '{}': { label: ['a', 'b', 'c'] } }, }); const value = Plain.deserialize('{label!=}'); - const range = value.selection.merge({ anchorOffset: 8 }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(8).value; + const result = await instance.provideCompletionItems({ text: '!=', prefix: '', wrapperClasses: ['context-labels'], @@ -225,35 +241,30 @@ describe('Language completion provider', () => { ]); }); - it('returns a refresher on label context and unavailable metric', () => { + it('returns a refresher on label context and unavailable metric', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } }); const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(7).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], value: valueWithSelection, }); expect(result.context).toBeUndefined(); - expect(result.refresher).toBeInstanceOf(Promise); expect(result.suggestions).toEqual([]); }); - it('returns label values on label context when given a metric and a label key', () => { + it('returns label values on label context when given a metric and a label key', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] }, labelValues: { '{__name__="metric"}': { bar: ['baz'] } }, }); const value = Plain.deserialize('metric{bar=ba}'); - const range = value.selection.merge({ - anchorOffset: 13, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(13).value; + const result = await instance.provideCompletionItems({ text: '=ba', prefix: 'ba', wrapperClasses: ['context-labels'], @@ -264,14 +275,12 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); }); - it('returns label suggestions on aggregation context and metric w/ selector', () => { + it('returns label suggestions on aggregation context and metric w/ selector', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } }); const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); - const range = value.selection.merge({ - anchorOffset: 26, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(26).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -281,14 +290,12 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label suggestions on aggregation context and metric w/o selector', () => { + it('returns label suggestions on aggregation context and metric w/o selector', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); const value = Plain.deserialize('sum(metric) by ()'); - const range = value.selection.merge({ - anchorOffset: 16, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(16).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -298,15 +305,16 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label suggestions inside a multi-line aggregation context', () => { + it('returns label suggestions inside a multi-line aggregation context', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum(\nmetric\n)\nby ()'); - const aggregationTextBlock = value.document.getBlocksAsArray()[3]; - const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const aggregationTextBlock = value.document.getBlocks().get(3); + const ed = new SlateEditor({ value }); + ed.moveToStartOfNode(aggregationTextBlock); + const valueWithSelection = ed.moveForward(4).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -321,16 +329,14 @@ describe('Language completion provider', () => { ]); }); - it('returns label suggestions inside an aggregation context with a range vector', () => { + it('returns label suggestions inside an aggregation context with a range vector', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum(rate(metric[1h])) by ()'); - const range = value.selection.merge({ - anchorOffset: 26, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(26).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -345,16 +351,14 @@ describe('Language completion provider', () => { ]); }); - it('returns label suggestions inside an aggregation context with a range vector and label', () => { + it('returns label suggestions inside an aggregation context with a range vector and label', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()'); - const range = value.selection.merge({ - anchorOffset: 42, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(42).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -369,16 +373,14 @@ describe('Language completion provider', () => { ]); }); - it('returns no suggestions inside an unclear aggregation context using alternate syntax', () => { + it('returns no suggestions inside an unclear aggregation context using alternate syntax', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum by ()'); - const range = value.selection.merge({ - anchorOffset: 8, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(8).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -388,16 +390,14 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([]); }); - it('returns label suggestions inside an aggregation context using alternate syntax', () => { + it('returns label suggestions inside an aggregation context using alternate syntax', async () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum by () (metric)'); - const range = value.selection.merge({ - anchorOffset: 8, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.provideCompletionItems({ + const ed = new SlateEditor({ value }); + const valueWithSelection = ed.moveForward(8).value; + const result = await instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 09114c247384..3b06385efe8b 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -23,11 +23,18 @@ import { import { Emitter } from 'app/core/core'; import TableModel from 'app/core/table_model'; +import { Value } from 'slate'; + +import { Editor } from '@grafana/slate-react'; export enum ExploreMode { Metrics = 'Metrics', Logs = 'Logs', } +export enum CompletionItemKind { + GroupTitle = 'GroupTitle', +} + export interface CompletionItem { /** * The label of this completion item. By default @@ -35,40 +42,48 @@ export interface CompletionItem { * this completion. */ label: string; + /** - * The kind of this completion item. Based on the kind - * an icon is chosen by the editor. + * The kind of this completion item. An icon is chosen + * by the editor based on the kind. */ - kind?: string; + kind?: CompletionItemKind | string; + /** * A human-readable string with additional information * about this item, like type or symbol information. */ detail?: string; + /** * A human-readable string, can be Markdown, that represents a doc-comment. */ documentation?: string; + /** * A string that should be used when comparing this item * with other items. When `falsy` the `label` is used. */ sortText?: string; + /** * A string that should be used when filtering a set of * completion items. When `falsy` the `label` is used. */ filterText?: string; + /** * A string or snippet that should be inserted in a document when selecting * this completion. When `falsy` the `label` is used. */ insertText?: string; + /** * Delete number of characters before the caret position, * by default the letters from the beginning of the word. */ deleteBackwards?: number; + /** * Number of steps to move after the insertion, can be negative. */ @@ -80,18 +95,22 @@ export interface CompletionItemGroup { * Label that will be displayed for all entries of this group. */ label: string; + /** * List of suggestions of this group. */ items: CompletionItem[]; + /** * If true, match only by prefix (and not mid-word). */ prefixMatch?: boolean; + /** * If true, do not filter items in this group based on the search. */ skipFilter?: boolean; + /** * If true, do not sort items. */ @@ -294,7 +313,7 @@ export interface HistoryItem { } export abstract class LanguageProvider { - datasource: any; + datasource: DataSourceApi; request: (url: string, params?: any) => Promise; /** * Returns startTask that resolves with a task list when main syntax is loaded. @@ -309,13 +328,12 @@ export interface TypeaheadInput { prefix: string; wrapperClasses: string[]; labelKey?: string; - //Should be Value from slate - value?: any; + value?: Value; + editor?: Editor; } export interface TypeaheadOutput { context?: string; - refresher?: Promise<{}>; suggestions: CompletionItemGroup[]; } diff --git a/public/sass/components/_slate_editor.scss b/public/sass/components/_slate_editor.scss index 50a58f8ff0cb..fe0d9c10f050 100644 --- a/public/sass/components/_slate_editor.scss +++ b/public/sass/components/_slate_editor.scss @@ -30,9 +30,9 @@ .typeahead { position: absolute; z-index: auto; - top: -10000px; - left: -10000px; - opacity: 0; + top: 100px; + left: 160px; + //opacity: 0; border-radius: $border-radius; border: $panel-border; max-height: calc(66vh); @@ -43,7 +43,7 @@ list-style: none; background: $panel-bg; color: $text-color; - transition: opacity 0.4s ease-out; + //transition: opacity 0.4s ease-out; box-shadow: $typeahead-shadow; } diff --git a/tsconfig.json b/tsconfig.json index cffbb3319694..da5818b01de2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,8 @@ "typeRoots": ["node_modules/@types", "public/app/types"], "paths": { "app": ["app"], - "sass": ["sass"] + "sass": ["sass"], + "@grafana/slate-react": ["../node_modules/@types/slate-react"] }, "skipLibCheck": true, "preserveSymlinks": true diff --git a/yarn.lock b/yarn.lock index 3493e5c28db6..1f6b4fe03350 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,6 +1227,28 @@ unique-filename "^1.1.1" which "^1.3.1" +"@grafana/slate-react@0.22.9-grafana": + version "0.22.9-grafana" + resolved "https://registry.yarnpkg.com/@grafana/slate-react/-/slate-react-0.22.9-grafana.tgz#07f35f0ffc018f616b9f82fa6e5ba65fae75c6a0" + integrity sha512-9NYjwabVOUQ/e4Y/Wm+sgePM65rb/gju59D52t4O42HsIm9exXv+SLajEBF/HiLHzuH5V+5uuHajbzv0vuE2VA== + dependencies: + debug "^3.1.0" + get-window "^1.1.1" + is-window "^1.0.2" + lodash "^4.1.1" + memoize-one "^4.0.0" + prop-types "^15.5.8" + react-immutable-proptypes "^2.1.0" + selection-is-backward "^1.0.0" + slate-base64-serializer "^0.2.111" + slate-dev-environment "^0.2.2" + slate-hotkeys "^0.2.9" + slate-plain-serializer "^0.7.10" + slate-prop-types "^0.5.41" + slate-react-placeholder "^0.2.8" + tiny-invariant "^1.0.1" + tiny-warning "^0.0.3" + "@icons/material@^0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" @@ -3408,10 +3430,26 @@ version "7.0.11" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88" -"@types/slate@0.44.11": - version "0.44.11" - resolved "https://registry.yarnpkg.com/@types/slate/-/slate-0.44.11.tgz#152568096d1a089fa4c5bb03de1cf044a377206c" - integrity sha512-UnOGipgkE1+rq3L4JjsTO0b02FbT6b59+0/hkW/QFBDvCcxCSAdwdr9HYjXkMSCSVlcsEfdC/cz+XOaB+tGvlg== +"@types/slate-plain-serializer@0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@types/slate-plain-serializer/-/slate-plain-serializer-0.6.1.tgz#c392ce51621f7c55df0976f161dcfca18bd559ee" + integrity sha512-5meyKFvmWH1T02j2dbAaY8kn/FNofxP79jV3TsfuLsUIeHkON5CroBxAyrgkYF4vHp+MVWZddI36Yvwl7Y0Feg== + dependencies: + "@types/slate" "*" + +"@types/slate-react@0.22.5": + version "0.22.5" + resolved "https://registry.yarnpkg.com/@types/slate-react/-/slate-react-0.22.5.tgz#a10796758aa6b3133e1c777959facbf8806959f7" + integrity sha512-WKJic5LlNRNUCnD6lEdlOZCcXWoDN8Ais2CmwVMn8pdt5Kh8hJsTYhXawNxOShPIOLVB+G+aVZNAXAAubEOpaw== + dependencies: + "@types/react" "*" + "@types/slate" "*" + immutable "^3.8.2" + +"@types/slate@*", "@types/slate@0.47.1": + version "0.47.1" + resolved "https://registry.yarnpkg.com/@types/slate/-/slate-0.47.1.tgz#6c66f82df085c764039eea2229be763f7e1906fd" + integrity sha512-2ZlnWI6/RYMXxeGFIeZtvmaXAeYAJh4ZVumziqVl77/liNEi9hOwkUTU2zFu+j/z21v385I2WVPl8sgadxfzXg== dependencies: "@types/react" "*" immutable "^3.8.2" @@ -4675,7 +4713,6 @@ bail@^1.0.0: balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= baron@3.0.3: version "3.0.3" @@ -4834,7 +4871,6 @@ boxen@^2.1.0: brace-expansion@^1.0.0, brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" @@ -5723,7 +5759,6 @@ compression@^1.5.2: concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= concat-stream@1.6.2, concat-stream@^1.4.6, concat-stream@^1.5.0: version "1.6.2" @@ -7154,6 +7189,7 @@ dir-glob@^2.0.0: direction@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/direction/-/direction-0.1.5.tgz#ce5d797f97e26f8be7beff53f7dc40e1c1a9ec4c" + integrity sha1-zl15f5fib4vnvv9T99xA4cGp7Ew= discontinuous-range@1.0.0: version "1.0.0" @@ -7745,6 +7781,7 @@ esrecurse@^4.1.0: esrever@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/esrever/-/esrever-0.2.0.tgz#96e9d28f4f1b1a76784cd5d490eaae010e7407b8" + integrity sha1-lunSj08bGnZ4TNXUkOquAQ50B7g= estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.2.0" @@ -8288,7 +8325,6 @@ for-in@^0.1.3: for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= for-own@^0.1.3, for-own@^0.1.4: version "0.1.5" @@ -8449,7 +8485,6 @@ fs-write-stream-atomic@^1.0.8, fs-write-stream-atomic@~1.0.10: fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: version "1.2.9" @@ -8902,9 +8937,8 @@ got@^6.7.1: url-parse-lax "^1.0.0" graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: - version "4.2.2" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02" - integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q== + version "4.1.15" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" "graceful-readlink@>= 1.0.0": version "1.0.1" @@ -9634,7 +9668,6 @@ infer-owner@^1.0.4: inflight@^1.0.4, inflight@~1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= dependencies: once "^1.3.0" wrappy "1" @@ -9943,10 +9976,6 @@ is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" -is-empty@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/is-empty/-/is-empty-1.2.0.tgz#de9bb5b278738a05a0b09a57e1fb4d4a341a9f6b" - is-equal-shallow@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" @@ -9960,7 +9989,6 @@ is-extendable@^0.1.0, is-extendable@^0.1.1: is-extendable@^1.0.0, is-extendable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" - integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== dependencies: is-plain-object "^2.0.4" @@ -10022,7 +10050,7 @@ is-hexadecimal@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835" -is-hotkey@0.1.4, is-hotkey@^0.1.1: +is-hotkey@0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.4.tgz#c34d2c85d6ec8d09a871dcf71931c8067a824c7d" @@ -10277,7 +10305,6 @@ isobject@^2.0.0: isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= isobject@^4.0.0: version "4.0.0" @@ -10914,7 +10941,7 @@ kew@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/kew/-/kew-0.7.0.tgz#79d93d2d33363d6fdd2970b335d9141ad591d79b" -keycode@^2.1.2, keycode@^2.2.0: +keycode@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04" @@ -11337,9 +11364,8 @@ lockfile@^1.0.4: signal-exit "^3.0.2" lodash-es@^4.17.11, lodash-es@^4.2.1: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" - integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" lodash._baseuniq@~4.6.0: version "4.6.0" @@ -11356,7 +11382,7 @@ lodash._getnative@^3.0.0: version "3.9.1" resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" -lodash._reinterpolate@^3.0.0: +lodash._reinterpolate@^3.0.0, lodash._reinterpolate@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= @@ -11427,9 +11453,8 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" lodash.mergewith@^4.6.1: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" - integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" lodash.once@^4.1.1: version "4.1.1" @@ -11452,7 +11477,7 @@ lodash.tail@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" -lodash.template@^4.0.2, lodash.template@^4.2.4: +lodash.template@^4.0.2: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== @@ -11460,12 +11485,20 @@ lodash.template@^4.0.2, lodash.template@^4.2.4: lodash._reinterpolate "^3.0.0" lodash.templatesettings "^4.0.0" +lodash.template@^4.2.4: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" + integrity sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A= + dependencies: + lodash._reinterpolate "~3.0.0" + lodash.templatesettings "^4.0.0" + lodash.templatesettings@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33" - integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ== + version "4.1.0" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316" + integrity sha1-K01OlbpEDZFf8IvImeRVNmZxMxY= dependencies: - lodash._reinterpolate "^3.0.0" + lodash._reinterpolate "~3.0.0" lodash.throttle@^4.1.1: version "4.1.1" @@ -11968,7 +12001,6 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: "minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.0, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" @@ -11989,7 +12021,6 @@ minimist-options@^3.0.1: minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= minimist@1.1.x: version "1.1.3" @@ -12032,9 +12063,8 @@ mississippi@^3.0.0: through2 "^2.0.0" mixin-deep@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" - integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" dependencies: for-in "^1.0.2" is-extendable "^1.0.1" @@ -12935,7 +12965,6 @@ on-headers@~1.0.2: once@^1.3.0, once@^1.3.1, once@^1.4.0, once@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= dependencies: wrappy "1" @@ -13358,7 +13387,6 @@ path-exists@^3.0.0: path-is-absolute@^1.0.0, path-is-absolute@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= path-is-inside@^1.0.1, path-is-inside@^1.0.2, path-is-inside@~1.0.2: version "1.0.2" @@ -14347,7 +14375,7 @@ pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" -prismjs@1.16.0, prismjs@^1.13.0, prismjs@^1.8.4, prismjs@~1.16.0: +prismjs@1.16.0, prismjs@^1.8.4, prismjs@~1.16.0: version "1.16.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.16.0.tgz#406eb2c8aacb0f5f0f1167930cb83835d10a4308" optionalDependencies: @@ -15072,12 +15100,6 @@ react-popper@^1.3.3: typed-styles "^0.0.7" warning "^4.0.2" -react-portal@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.2.0.tgz#4224e19b2b05d5cbe730a7ba0e34ec7585de0043" - dependencies: - prop-types "^15.5.8" - react-redux@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.1.1.tgz#88e368682c7fa80e34e055cd7ac56f5936b0f52f" @@ -16485,81 +16507,55 @@ slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" -slate-base64-serializer@^0.2.36: - version "0.2.102" - resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.102.tgz#05cdb9149172944b55c8d0a0d14b4499a1c3b5a2" +slate-base64-serializer@^0.2.111: + version "0.2.111" + resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.111.tgz#22ba7d32aa4650f6bbd25c26ffe11f5d021959d6" + integrity sha512-pEsbxz4msVSCCCkn7rX+lHXxUj/oddcR4VsIYwWeQQLm9Uw7Ovxja4rQ/hVFcQqoU2DIjITRwBR9pv3RyS+PZQ== dependencies: isomorphic-base64 "^1.0.2" -slate-dev-environment@^0.1.2, slate-dev-environment@^0.1.4: - version "0.1.6" - resolved "https://registry.yarnpkg.com/slate-dev-environment/-/slate-dev-environment-0.1.6.tgz#ff22b40ef4cc890ff7706b6b657abc276782424f" +slate-dev-environment@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/slate-dev-environment/-/slate-dev-environment-0.2.2.tgz#bd8946e1fe4cf5447060c84a362a1d026ed8b77f" + integrity sha512-JZ09llrRQu6JUsLJCUlGC0lB1r1qIAabAkSd454iyYBq6lDuY//Bypi3Jo8yzIfzZ4+mRLdQvl9e8MbeM9l48Q== dependencies: is-in-browser "^1.1.3" -slate-dev-logger@^0.1.39, slate-dev-logger@^0.1.43: - version "0.1.43" - resolved "https://registry.yarnpkg.com/slate-dev-logger/-/slate-dev-logger-0.1.43.tgz#77f6ca7207fcbf453a5516f3aa8b19794d1d26dc" - -slate-hotkeys@^0.1.2: - version "0.1.4" - resolved "https://registry.yarnpkg.com/slate-hotkeys/-/slate-hotkeys-0.1.4.tgz#5b10b2a178affc60827f9284d4c0a5d7e5041ffe" +slate-hotkeys@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/slate-hotkeys/-/slate-hotkeys-0.2.9.tgz#0cc9eb750a49ab9ef11601305b7c82b5402348e3" + integrity sha512-y+C/s5vJEmBxo8fIqHmUcdViGwALL/A6Qow3sNG1OHYD5SI11tC2gfYtGbPh+2q0H7O4lufffCmFsP5bMaDHqA== dependencies: - is-hotkey "^0.1.1" - slate-dev-environment "^0.1.4" + is-hotkey "0.1.4" + slate-dev-environment "^0.2.2" -slate-plain-serializer@0.5.41, slate-plain-serializer@^0.5.17: - version "0.5.41" - resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.5.41.tgz#dc2d219602c2cb8dc710ac660e108f3b3cc4dc80" - dependencies: - slate-dev-logger "^0.1.43" - -slate-prism@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/slate-prism/-/slate-prism-0.5.0.tgz#009eb74fea38ad76c64db67def7ea0884917adec" - dependencies: - prismjs "^1.13.0" +slate-plain-serializer@0.7.10, slate-plain-serializer@^0.7.10: + version "0.7.10" + resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.10.tgz#bc4a6942cf52fde826019bb1095dffd0dac8cc08" + integrity sha512-/QvMCQ0F3NzbnuoW+bxsLIChPdRgxBjQeGhYhpRGTVvlZCLOmfDvavhN6fHsuEwkvdwOmocNF30xT1WVlmibYg== -slate-prop-types@^0.4.34: - version "0.4.67" - resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.4.67.tgz#c6aa74195466546a44fcb85d1c7b15fefe36ce6b" - -slate-react@0.12.11: - version "0.12.11" - resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.12.11.tgz#6d83e604634704757690a57dbd6aab282a964ad3" - dependencies: - debug "^3.1.0" - get-window "^1.1.1" - is-window "^1.0.2" - keycode "^2.1.2" - lodash "^4.1.1" - prop-types "^15.5.8" - react-immutable-proptypes "^2.1.0" - react-portal "^3.1.0" - selection-is-backward "^1.0.0" - slate-base64-serializer "^0.2.36" - slate-dev-environment "^0.1.2" - slate-dev-logger "^0.1.39" - slate-hotkeys "^0.1.2" - slate-plain-serializer "^0.5.17" - slate-prop-types "^0.4.34" +slate-prop-types@^0.5.41: + version "0.5.41" + resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.5.41.tgz#42031881e2fef4fa978a96b9aad84b093b4a5219" + integrity sha512-fLcXlugO9btF5b/by+dA+n8fn2mET75VGWltqFNxGdl6ncyBtrGspWA7mLVRFSqQWOS/Ig4A3URCRumOBBCUfQ== -slate-schema-violations@^0.1.12: - version "0.1.39" - resolved "https://registry.yarnpkg.com/slate-schema-violations/-/slate-schema-violations-0.1.39.tgz#854ab5624136419cef4c803b1823acabe11f1c15" +slate-react-placeholder@^0.2.8: + version "0.2.8" + resolved "https://registry.yarnpkg.com/slate-react-placeholder/-/slate-react-placeholder-0.2.8.tgz#973ac47c9a518a1418e89b6021b0f6120c07ce6f" + integrity sha512-CZZSg5usE2ZY/AYg06NVcL9Wia6hD/Mg0w4D4e9rPh6hkkFJg8LZXYMRz+6Q4v1dqHmzRsZ2Ixa0jRuiKXsMaQ== -slate@0.33.8: - version "0.33.8" - resolved "https://registry.yarnpkg.com/slate/-/slate-0.33.8.tgz#c2cd9906c446d010b15e9e28f6d1a01792c7a113" +slate@0.47.8: + version "0.47.8" + resolved "https://registry.yarnpkg.com/slate/-/slate-0.47.8.tgz#1e987b74d8216d44ec56154f0e6d3c722ce21e6e" + integrity sha512-/Jt0eq4P40qZvtzeKIvNb+1N97zVICulGQgQoMDH0TI8h8B+5kqa1YeckRdRnuvfYJm3J/9lWn2V3J1PrF+hag== dependencies: debug "^3.1.0" direction "^0.1.5" esrever "^0.2.0" - is-empty "^1.0.0" is-plain-object "^2.0.4" lodash "^4.17.4" - slate-dev-logger "^0.1.39" - slate-schema-violations "^0.1.12" + tiny-invariant "^1.0.1" + tiny-warning "^0.0.3" type-of "^2.0.1" slice-ansi@0.0.4: @@ -17466,14 +17462,20 @@ tiny-emitter@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" -tiny-invariant@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.4.tgz#346b5415fd93cb696b0c4e8a96697ff590f92463" +tiny-invariant@^1.0.1, tiny-invariant@^1.0.2: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" + integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== tiny-relative-date@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07" +tiny-warning@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-0.0.3.tgz#1807eb4c5f81784a6354d58ea1d5024f18c6c81f" + integrity sha512-r0SSA5Y5IWERF9Xh++tFPx0jITBgGggOsRLDWWew6YRw/C2dr4uNO1fw1vanrBmHsICmPyMLNBZboTlxUmUuaA== + tiny-warning@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28" @@ -17782,6 +17784,7 @@ type-name@^2.0.1: type-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/type-of/-/type-of-2.0.1.tgz#e72a1741896568e9f628378d816d6912f7f23972" + integrity sha1-5yoXQYllaOn2KDeNgW1pEvfyOXI= typed-styles@^0.0.7: version "0.0.7" @@ -18628,7 +18631,6 @@ wrap-ansi@^5.1.0: wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= write-file-atomic@2.4.1: version "2.4.1" From 503dccb771ffa5cdf22ecdb9fccef5307ea372d8 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 13:21:50 +0200 Subject: [PATCH 80/87] Revert "Chore: Update Slate to 0.47.8 (#18412)" (#19167) This reverts commit 601853fc8453f73fb5df4bd0fe8c2a228aed2d3d. --- package.json | 11 +- .../src/config/webpack.plugin.config.ts | 2 +- packages/grafana-ui/package.json | 5 - packages/grafana-ui/rollup.config.ts | 7 +- .../components/DataLinks/DataLinkInput.tsx | 46 +- packages/grafana-ui/src/index.ts | 1 - .../grafana-ui/src/slate-plugins/index.ts | 1 - .../slate-plugins/slate-prism/TOKEN_MARK.ts | 3 - .../src/slate-plugins/slate-prism/index.ts | 160 ----- .../src/slate-plugins/slate-prism/options.tsx | 77 --- packages/grafana-ui/src/utils/slate.ts | 27 +- packages/grafana-ui/tsconfig.json | 4 - .../app/features/explore/QueryField.test.tsx | 41 ++ public/app/features/explore/QueryField.tsx | 607 ++++++++++++++++-- public/app/features/explore/Typeahead.tsx | 224 +++---- public/app/features/explore/TypeaheadInfo.tsx | 25 +- public/app/features/explore/TypeaheadItem.tsx | 36 +- .../explore/slate-plugins/braces.test.ts | 39 ++ .../explore/slate-plugins/braces.test.tsx | 40 -- .../features/explore/slate-plugins/braces.ts | 40 +- .../explore/slate-plugins/clear.test.ts | 39 ++ .../explore/slate-plugins/clear.test.tsx | 42 -- .../features/explore/slate-plugins/clear.ts | 21 +- .../explore/slate-plugins/clipboard.ts | 61 -- .../explore/slate-plugins/indentation.ts | 93 --- .../features/explore/slate-plugins/newline.ts | 21 +- .../explore/slate-plugins/runner.test.tsx | 17 - .../features/explore/slate-plugins/runner.ts | 7 +- .../slate-plugins/selection_shortcuts.ts | 72 --- .../explore/slate-plugins/suggestions.tsx | 313 --------- .../app/features/explore/utils/typeahead.ts | 8 +- public/app/features/plugins/plugin_loader.ts | 4 +- .../components/ElasticsearchQueryField.tsx | 8 +- .../editor/KustoQueryField.tsx | 17 +- .../editor/query_field.tsx | 59 +- .../loki/components/LokiQueryField.tsx | 3 +- .../loki/components/LokiQueryFieldForm.tsx | 49 +- .../loki/components/useLokiSyntax.test.ts | 1 - .../loki/components/useLokiSyntax.ts | 1 + .../datasource/loki/language_provider.test.ts | 57 +- .../datasource/loki/language_provider.ts | 54 +- public/app/plugins/datasource/loki/mocks.ts | 4 +- public/app/plugins/datasource/loki/syntax.ts | 4 +- .../prometheus/components/PromQueryField.tsx | 34 +- .../prometheus/language_provider.ts | 145 ++--- .../datasource/prometheus/language_utils.ts | 8 +- .../specs/language_provider.test.ts | 222 +++---- public/app/types/explore.ts | 32 +- public/sass/components/_slate_editor.scss | 8 +- tsconfig.json | 3 +- yarn.lock | 220 ++++--- 51 files changed, 1318 insertions(+), 1705 deletions(-) delete mode 100644 packages/grafana-ui/src/slate-plugins/index.ts delete mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/TOKEN_MARK.ts delete mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/index.ts delete mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx create mode 100644 public/app/features/explore/slate-plugins/braces.test.ts delete mode 100644 public/app/features/explore/slate-plugins/braces.test.tsx create mode 100644 public/app/features/explore/slate-plugins/clear.test.ts delete mode 100644 public/app/features/explore/slate-plugins/clear.test.tsx delete mode 100644 public/app/features/explore/slate-plugins/clipboard.ts delete mode 100644 public/app/features/explore/slate-plugins/indentation.ts delete mode 100644 public/app/features/explore/slate-plugins/runner.test.tsx delete mode 100644 public/app/features/explore/slate-plugins/selection_shortcuts.ts delete mode 100644 public/app/features/explore/slate-plugins/suggestions.tsx diff --git a/package.json b/package.json index 0f9a5d882f13..a53ab845d1ea 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,7 @@ "@types/redux-logger": "3.0.7", "@types/redux-mock-store": "1.0.1", "@types/reselect": "2.2.0", - "@types/slate": "0.47.1", - "@types/slate-plain-serializer": "0.6.1", - "@types/slate-react": "0.22.5", + "@types/slate": "0.44.11", "@types/tinycolor2": "1.4.2", "angular-mocks": "1.6.6", "autoprefixer": "9.5.0", @@ -195,7 +193,6 @@ }, "dependencies": { "@babel/polyfill": "7.2.5", - "@grafana/slate-react": "0.22.9-grafana", "@torkelo/react-select": "2.4.1", "angular": "1.6.6", "angular-bindonce": "0.3.1", @@ -246,8 +243,10 @@ "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "6.4.0", "search-query-parser": "1.5.2", - "slate": "0.47.8", - "slate-plain-serializer": "0.7.10", + "slate": "0.33.8", + "slate-plain-serializer": "0.5.41", + "slate-prism": "0.5.0", + "slate-react": "0.12.11", "tether": "1.4.5", "tether-drop": "https://github.com/torkelo/drop/tarball/master", "tinycolor2": "1.4.1", diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 34c663c4fe83..07b9f350cb82 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -149,7 +149,7 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { 'emotion', 'prismjs', 'slate-plain-serializer', - '@grafana/slate-react', + 'slate-react', 'react', 'react-dom', 'react-redux', diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index ac98227b8edb..6fdc543fdb48 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -26,12 +26,10 @@ }, "dependencies": { "@grafana/data": "^6.4.0-alpha", - "@grafana/slate-react": "0.22.9-grafana", "@torkelo/react-select": "2.1.1", "@types/react-color": "2.17.0", "classnames": "2.2.6", "d3": "5.9.1", - "immutable": "3.8.2", "jquery": "3.4.1", "lodash": "4.17.15", "moment": "2.24.0", @@ -47,7 +45,6 @@ "react-storybook-addon-props-combinations": "1.1.0", "react-transition-group": "2.6.1", "react-virtualized": "9.21.0", - "slate": "0.47.8", "tinycolor2": "1.4.1" }, "devDependencies": { @@ -68,8 +65,6 @@ "@types/react-custom-scrollbars": "4.0.5", "@types/react-test-renderer": "16.8.1", "@types/react-transition-group": "2.0.16", - "@types/slate": "0.47.1", - "@types/slate-react": "0.22.5", "@types/storybook__addon-actions": "3.4.2", "@types/storybook__addon-info": "4.1.1", "@types/storybook__addon-knobs": "4.0.4", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index c79a2084fdd1..85564fa54e02 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -1,6 +1,6 @@ import resolve from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; -// import sourceMaps from 'rollup-plugin-sourcemaps'; +import sourceMaps from 'rollup-plugin-sourcemaps'; import { terser } from 'rollup-plugin-terser'; const pkg = require('./package.json'); @@ -47,20 +47,19 @@ const buildCjsPackage = ({ env }) => { ], '../../node_modules/react-color/lib/components/common': ['Saturation', 'Hue', 'Alpha'], '../../node_modules/immutable/dist/immutable.js': [ - 'Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack', + 'Record', ], - 'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'], '../../node_modules/esrever/esrever.js': ['reverse'], }, }), resolve(), - // sourceMaps(), + sourceMaps(), env === 'production' && terser(), ], }; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 5b57c51aa758..a9c15f4be7b8 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -1,16 +1,19 @@ import React, { useState, useMemo, useCallback, useContext } from 'react'; import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions'; -import { makeValue, ThemeContext, DataLinkBuiltInVars, SCHEMA } from '../../index'; +import { makeValue, ThemeContext, DataLinkBuiltInVars } from '../../index'; import { SelectionReference } from './SelectionReference'; import { Portal } from '../index'; -import { Editor } from '@grafana/slate-react'; -import { Value, Editor as CoreEditor } from 'slate'; +// @ts-ignore +import { Editor } from 'slate-react'; +// @ts-ignore +import { Value, Change, Document } from 'slate'; +// @ts-ignore import Plain from 'slate-plain-serializer'; import { Popper as ReactPopper } from 'react-popper'; import useDebounce from 'react-use/lib/useDebounce'; import { css, cx } from 'emotion'; - -import { SlatePrism } from '../../slate-plugins'; +// @ts-ignore +import PluginPrism from 'slate-prism'; interface DataLinkInputProps { value: string; @@ -19,7 +22,7 @@ interface DataLinkInputProps { } const plugins = [ - SlatePrism({ + PluginPrism({ onlyIn: (node: any) => node.type === 'code_block', getSyntax: () => 'links', }), @@ -76,28 +79,27 @@ export const DataLinkInput: React.FC = ({ value, onChange, s useDebounce(updateUsedSuggestions, 250, [linkUrl]); - const onKeyDown = (event: Event, editor: CoreEditor, next: Function) => { - const keyboardEvent = event as KeyboardEvent; - if (keyboardEvent.key === 'Backspace') { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Backspace' || event.key === 'Escape') { setShowingSuggestions(false); setSuggestionsIndex(0); } - if (keyboardEvent.key === 'Enter') { + if (event.key === 'Enter') { if (showingSuggestions) { onVariableSelect(currentSuggestions[suggestionsIndex]); } } if (showingSuggestions) { - if (keyboardEvent.key === 'ArrowDown') { - keyboardEvent.preventDefault(); + if (event.key === 'ArrowDown') { + event.preventDefault(); setSuggestionsIndex(index => { return (index + 1) % currentSuggestions.length; }); } - if (keyboardEvent.key === 'ArrowUp') { - keyboardEvent.preventDefault(); + if (event.key === 'ArrowUp') { + event.preventDefault(); setSuggestionsIndex(index => { const nextIndex = index - 1 < 0 ? currentSuggestions.length - 1 : (index - 1) % currentSuggestions.length; return nextIndex; @@ -105,24 +107,21 @@ export const DataLinkInput: React.FC = ({ value, onChange, s } } - if ( - keyboardEvent.key === '?' || - keyboardEvent.key === '&' || - keyboardEvent.key === '$' || - (keyboardEvent.keyCode === 32 && keyboardEvent.ctrlKey) - ) { + if (event.key === '?' || event.key === '&' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { setShowingSuggestions(true); } - if (keyboardEvent.key === 'Backspace') { - return next(); + if (event.key === 'Enter' && showingSuggestions) { + // Preventing entering a new line + // As of https://github.com/ianstormtaylor/slate/issues/1345#issuecomment-340508289 + return false; } else { // @ts-ignore return; } }; - const onUrlChange = ({ value }: { value: Value }) => { + const onUrlChange = ({ value }: Change) => { setLinkUrl(value); }; @@ -187,7 +186,6 @@ export const DataLinkInput: React.FC = ({ value, onChange, s )} { - if (!opts.onlyIn(node)) { - return next(); - } - return decorateNode(opts, Block.create(node as Block)); - }, - - renderDecoration: (props, editor, next) => - opts.renderDecoration( - { - children: props.children, - decoration: props.decoration, - }, - editor as any, - next - ), - }; -} - -/** - * Returns the decoration for a node - */ -function decorateNode(opts: Options, block: Block) { - const grammarName = opts.getSyntax(block); - const grammar = Prism.languages[grammarName]; - if (!grammar) { - // Grammar not loaded - return []; - } - - // Tokenize the whole block text - const texts = block.getTexts(); - const blockText = texts.map(text => text && text.getText()).join('\n'); - const tokens = Prism.tokenize(blockText, grammar); - - // The list of decorations to return - const decorations: Decoration[] = []; - let textStart = 0; - let textEnd = 0; - - texts.forEach(text => { - textEnd = textStart + text!.getText().length; - - let offset = 0; - function processToken(token: string | Prism.Token, accu?: string | number) { - if (typeof token === 'string') { - if (accu) { - const decoration = createDecoration({ - text: text!, - textStart, - textEnd, - start: offset, - end: offset + token.length, - className: `prism-token token ${accu}`, - block, - }); - if (decoration) { - decorations.push(decoration); - } - } - offset += token.length; - } else { - accu = `${accu} ${token.type} ${token.alias || ''}`; - - if (typeof token.content === 'string') { - const decoration = createDecoration({ - text: text!, - textStart, - textEnd, - start: offset, - end: offset + token.content.length, - className: `prism-token token ${accu}`, - block, - }); - if (decoration) { - decorations.push(decoration); - } - - offset += token.content.length; - } else { - // When using token.content instead of token.matchedStr, token can be deep - for (let i = 0; i < token.content.length; i += 1) { - // @ts-ignore - processToken(token.content[i], accu); - } - } - } - } - - tokens.forEach(processToken); - textStart = textEnd + 1; // account for added `\n` - }); - - return decorations; -} - -/** - * Return a decoration range for the given text. - */ -function createDecoration({ - text, - textStart, - textEnd, - start, - end, - className, - block, -}: { - text: Text; // The text being decorated - textStart: number; // Its start position in the whole text - textEnd: number; // Its end position in the whole text - start: number; // The position in the whole text where the token starts - end: number; // The position in the whole text where the token ends - className: string; // The prism token classname - block: Block; -}): Decoration | null { - if (start >= textEnd || end <= textStart) { - // Ignore, the token is not in the text - return null; - } - - // Shrink to this text boundaries - start = Math.max(start, textStart); - end = Math.min(end, textEnd); - - // Now shift offsets to be relative to this text - start -= textStart; - end -= textStart; - - const myDec = block.createDecoration({ - object: 'decoration', - anchor: { - key: text.key, - offset: start, - object: 'point', - }, - focus: { - key: text.key, - offset: end, - object: 'point', - }, - type: TOKEN_MARK, - data: { className }, - }); - - return myDec; -} diff --git a/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx b/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx deleted file mode 100644 index 82320a5a1321..000000000000 --- a/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import { Mark, Node, Decoration } from 'slate'; -import { Editor } from '@grafana/slate-react'; -import { Record } from 'immutable'; - -import TOKEN_MARK from './TOKEN_MARK'; - -export interface OptionsFormat { - // Determine which node should be highlighted - onlyIn?: (node: Node) => boolean; - // Returns the syntax for a node that should be highlighted - getSyntax?: (node: Node) => string; - // Render a highlighting mark in a highlighted node - renderMark?: ({ mark, children }: { mark: Mark; children: React.ReactNode }) => void | React.ReactNode; -} - -/** - * Default filter for code blocks - */ -function defaultOnlyIn(node: Node): boolean { - return node.object === 'block' && node.type === 'code_block'; -} - -/** - * Default getter for syntax - */ -function defaultGetSyntax(node: Node): string { - return 'javascript'; -} - -/** - * Default rendering for decorations - */ -function defaultRenderDecoration( - props: { children: React.ReactNode; decoration: Decoration }, - editor: Editor, - next: () => any -): void | React.ReactNode { - const { decoration } = props; - if (decoration.type !== TOKEN_MARK) { - return next(); - } - - const className = decoration.data.get('className'); - return {props.children}; -} - -/** - * The plugin options - */ -class Options - extends Record({ - onlyIn: defaultOnlyIn, - getSyntax: defaultGetSyntax, - renderDecoration: defaultRenderDecoration, - }) - implements OptionsFormat { - readonly onlyIn!: (node: Node) => boolean; - readonly getSyntax!: (node: Node) => string; - readonly renderDecoration!: ( - { - decoration, - children, - }: { - decoration: Decoration; - children: React.ReactNode; - }, - editor: Editor, - next: () => any - ) => void | React.ReactNode; - - constructor(props: OptionsFormat) { - super(props); - } -} - -export default Options; diff --git a/packages/grafana-ui/src/utils/slate.ts b/packages/grafana-ui/src/utils/slate.ts index fcff5e431077..e8a8dd71295b 100644 --- a/packages/grafana-ui/src/utils/slate.ts +++ b/packages/grafana-ui/src/utils/slate.ts @@ -1,22 +1,22 @@ -import { Block, Document, Text, Value, SchemaProperties } from 'slate'; +// @ts-ignore +import { Block, Document, Text, Value } from 'slate'; -export const SCHEMA: SchemaProperties = { - document: { - nodes: [ - { - match: [{ type: 'paragraph' }, { type: 'code_block' }, { type: 'code_line' }], - }, - ], +const SCHEMA = { + blocks: { + paragraph: 'paragraph', + codeblock: 'code_block', + codeline: 'code_line', }, inlines: {}, + marks: {}, }; -export const makeFragment = (text: string, syntax?: string): Document => { +export const makeFragment = (text: string, syntax?: string) => { const lines = text.split('\n').map(line => Block.create({ type: 'code_line', nodes: [Text.create(line)], - }) + } as any) ); const block = Block.create({ @@ -25,17 +25,18 @@ export const makeFragment = (text: string, syntax?: string): Document => { }, type: 'code_block', nodes: lines, - }); + } as any); return Document.create({ nodes: [block], }); }; -export const makeValue = (text: string, syntax?: string): Value => { +export const makeValue = (text: string, syntax?: string) => { const fragment = makeFragment(text, syntax); return Value.create({ document: fragment, - }); + SCHEMA, + } as any); }; diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index 883bbe99ab1c..d6dbfc1e0b73 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/packages/grafana-ui/tsconfig.json @@ -5,10 +5,6 @@ "compilerOptions": { "rootDirs": [".", "stories"], "typeRoots": ["./node_modules/@types", "types"], - "baseUrl": "./node_modules/@types", - "paths": { - "@grafana/slate-react": ["slate-react"] - }, "declarationDir": "dist", "outDir": "compiled" } diff --git a/public/app/features/explore/QueryField.test.tsx b/public/app/features/explore/QueryField.test.tsx index e09f00a7b9e9..274de4e7ceb0 100644 --- a/public/app/features/explore/QueryField.test.tsx +++ b/public/app/features/explore/QueryField.test.tsx @@ -17,4 +17,45 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.find('div').exists()).toBeTruthy(); }); + + it('should execute query when enter is pressed and there are no suggestions visible', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as QueryField; + instance.executeOnChangeAndRunQueries = jest.fn(); + const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey'); + instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {}); + expect(handleEnterAndTabKeySpy).toBeCalled(); + expect(instance.executeOnChangeAndRunQueries).toBeCalled(); + }); + + it('should copy selected text', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as QueryField; + const textBlocks = ['ignore this text. copy this text']; + const copiedText = instance.getCopiedText(textBlocks, 18, 32); + + expect(copiedText).toBe('copy this text'); + }); + + it('should copy selected text across 2 lines', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as QueryField; + const textBlocks = ['ignore this text. start copying here', 'lorem ipsum. stop copying here. lorem ipsum']; + const copiedText = instance.getCopiedText(textBlocks, 18, 30); + + expect(copiedText).toBe('start copying here\nlorem ipsum. stop copying here'); + }); + + it('should copy selected text across > 2 lines', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as QueryField; + const textBlocks = [ + 'ignore this text. start copying here', + 'lorem ipsum doler sit amet', + 'lorem ipsum. stop copying here. lorem ipsum', + ]; + const copiedText = instance.getCopiedText(textBlocks, 18, 30); + + expect(copiedText).toBe('start copying here\nlorem ipsum doler sit amet\nlorem ipsum. stop copying here'); + }); }); diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index d2d58055c5ff..8a61a4397e80 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -1,36 +1,55 @@ import _ from 'lodash'; import React, { Context } from 'react'; - -import { Value, Editor as CoreEditor } from 'slate'; -import { Editor, Plugin } from '@grafana/slate-react'; +import ReactDOM from 'react-dom'; +// @ts-ignore +import { Change, Range, Value, Block } from 'slate'; +// @ts-ignore +import { Editor } from 'slate-react'; +// @ts-ignore import Plain from 'slate-plain-serializer'; import classnames from 'classnames'; +// @ts-ignore +import { isKeyHotkey } from 'is-hotkey'; -import { CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; +import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; import ClearPlugin from './slate-plugins/clear'; import NewlinePlugin from './slate-plugins/newline'; -import SelectionShortcutsPlugin from './slate-plugins/selection_shortcuts'; -import IndentationPlugin from './slate-plugins/indentation'; -import ClipboardPlugin from './slate-plugins/clipboard'; -import RunnerPlugin from './slate-plugins/runner'; -import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions'; - -import { Typeahead } from './Typeahead'; -import { makeValue, SCHEMA } from '@grafana/ui'; +import { TypeaheadWithTheme } from './Typeahead'; +import { makeFragment, makeValue } from '@grafana/ui'; +export const TYPEAHEAD_DEBOUNCE = 100; export const HIGHLIGHT_WAIT = 500; +const SLATE_TAB = ' '; +const isIndentLeftHotkey = isKeyHotkey('mod+['); +const isIndentRightHotkey = isKeyHotkey('mod+]'); +const isSelectLeftHotkey = isKeyHotkey('shift+left'); +const isSelectRightHotkey = isKeyHotkey('shift+right'); +const isSelectUpHotkey = isKeyHotkey('shift+up'); +const isSelectDownHotkey = isKeyHotkey('shift+down'); +const isSelectLineHotkey = isKeyHotkey('mod+l'); + +function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem { + // Flatten suggestion groups + const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []); + const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length; + return flattenedSuggestions[correctedIndex]; +} + +function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { + return suggestions && suggestions.length > 0; +} export interface QueryFieldProps { - additionalPlugins?: Plugin[]; + additionalPlugins?: any[]; cleanText?: (text: string) => string; disabled?: boolean; initialQuery: string | null; onRunQuery?: () => void; onChange?: (value: string) => void; - onTypeahead?: (typeahead: TypeaheadInput) => Promise; - onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; + onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput; + onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string; placeholder?: string; portalOrigin?: string; syntax?: string; @@ -40,19 +59,20 @@ export interface QueryFieldProps { export interface QueryFieldState { suggestions: CompletionItemGroup[]; typeaheadContext: string | null; + typeaheadIndex: number; typeaheadPrefix: string; typeaheadText: string; - value: Value; + value: any; lastExecutedValue: Value; } export interface TypeaheadInput { + editorNode: Element; prefix: string; selection?: Selection; text: string; value: Value; - wrapperClasses: string[]; - labelKey?: string; + wrapperNode: Element; } /** @@ -63,35 +83,23 @@ export interface TypeaheadInput { */ export class QueryField extends React.PureComponent { menuEl: HTMLElement | null; - plugins: Plugin[]; - resetTimer: NodeJS.Timer; + plugins: any[]; + resetTimer: any; mounted: boolean; - updateHighlightsTimer: Function; - editor: Editor; - typeaheadRef: Typeahead; + updateHighlightsTimer: any; constructor(props: QueryFieldProps, context: Context) { super(props, context); this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT); - const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props; - // Base plugins - this.plugins = [ - SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }), - ClearPlugin(), - RunnerPlugin({ handler: this.executeOnChangeAndRunQueries }), - NewlinePlugin(), - SelectionShortcutsPlugin(), - IndentationPlugin(), - ClipboardPlugin(), - ...(props.additionalPlugins || []), - ].filter(p => p); + this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p); this.state = { suggestions: [], typeaheadContext: null, + typeaheadIndex: 0, typeaheadPrefix: '', typeaheadText: '', value: makeValue(props.initialQuery || '', props.syntax), @@ -101,6 +109,7 @@ export class QueryField extends React.PureComponent { + onChange = ({ value }: Change, invokeParentOnValueChanged?: boolean) => { const documentChanged = value.document !== this.state.value.document; const prevValue = this.state.value; @@ -145,6 +163,14 @@ export class QueryField extends React.PureComponent { @@ -168,18 +194,475 @@ export class QueryField extends React.PureComponent { + handleTypeahead = _.debounce(async () => { + const selection = window.getSelection(); + const { cleanText, onTypeahead } = this.props; + const { value } = this.state; + + if (onTypeahead && selection.anchorNode) { + const wrapperNode = selection.anchorNode.parentElement; + const editorNode = wrapperNode.closest('.slate-query-field'); + if (!editorNode || this.state.value.isBlurred) { + // Not inside this editor + return; + } + + const range = selection.getRangeAt(0); + const offset = range.startOffset; + const text = selection.anchorNode.textContent; + let prefix = text.substr(0, offset); + + // Label values could have valid characters erased if `cleanText()` is + // blindly applied, which would undesirably interfere with suggestions + const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); + if (labelValueMatch) { + prefix = labelValueMatch[1]; + } else if (cleanText) { + prefix = cleanText(prefix); + } + + const { suggestions, context, refresher } = onTypeahead({ + editorNode, + prefix, + selection, + text, + value, + wrapperNode, + }); + + let filteredSuggestions = suggestions + .map(group => { + if (group.items) { + if (prefix) { + // Filter groups based on prefix + if (!group.skipFilter) { + group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); + if (group.prefixMatch) { + group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0); + } else { + group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1); + } + } + // Filter out the already typed value (prefix) unless it inserts custom text + group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); + } + + if (!group.skipSort) { + group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); + } + } + return group; + }) + .filter(group => group.items && group.items.length > 0); // Filter out empty groups + + // Keep same object for equality checking later + if (_.isEqual(filteredSuggestions, this.state.suggestions)) { + filteredSuggestions = this.state.suggestions; + } + + this.setState( + { + suggestions: filteredSuggestions, + typeaheadPrefix: prefix, + typeaheadContext: context, + typeaheadText: text, + }, + () => { + if (refresher) { + refresher.then(this.handleTypeahead).catch(e => console.error(e)); + } + } + ); + } + }, TYPEAHEAD_DEBOUNCE); + + applyTypeahead(change: Change, suggestion: CompletionItem): Change { + const { cleanText, onWillApplySuggestion, syntax } = this.props; + const { typeaheadPrefix, typeaheadText } = this.state; + let suggestionText = suggestion.insertText || suggestion.label; + const preserveSuffix = suggestion.kind === 'function'; + const move = suggestion.move || 0; + + if (onWillApplySuggestion) { + suggestionText = onWillApplySuggestion(suggestionText, { ...this.state }); + } + + this.resetTypeahead(); + + // Remove the current, incomplete text and replace it with the selected suggestion + const backward = suggestion.deleteBackwards || typeaheadPrefix.length; + const text = cleanText ? cleanText(typeaheadText) : typeaheadText; + const suffixLength = text.length - typeaheadPrefix.length; + const offset = typeaheadText.indexOf(typeaheadPrefix); + const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); + const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; + + // If new-lines, apply suggestion as block + if (suggestionText.match(/\n/)) { + const fragment = makeFragment(suggestionText, syntax); + return change + .deleteBackward(backward) + .deleteForward(forward) + .insertFragment(fragment) + .focus(); + } + + return change + .deleteBackward(backward) + .deleteForward(forward) + .insertText(suggestionText) + .move(move) + .focus(); + } + + handleEnterKey = (event: KeyboardEvent, change: Change) => { + event.preventDefault(); + + if (event.shiftKey) { + // pass through if shift is pressed + return undefined; + } else if (!this.menuEl) { + this.executeOnChangeAndRunQueries(); + return true; + } else { + return this.selectSuggestion(change); + } + }; + + selectSuggestion = (change: Change) => { + const { typeaheadIndex, suggestions } = this.state; + event.preventDefault(); + + if (!suggestions || suggestions.length === 0) { + return undefined; + } + + const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex); + const nextChange = this.applyTypeahead(change, suggestion); + + const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text'); + return insertTextOperation ? true : undefined; + }; + + handleTabKey = (change: Change): void => { + const { + startBlock, + endBlock, + selection: { startOffset, startKey, endOffset, endKey }, + } = change.value; + + if (this.menuEl) { + this.selectSuggestion(change); + return; + } + + const first = startBlock.getFirstText(); + + const startBlockIsSelected = + startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key; + + if (startBlockIsSelected || !startBlock.equals(endBlock)) { + this.handleIndent(change, 'right'); + } else { + change.insertText(SLATE_TAB); + } + }; + + handleIndent = (change: Change, indentDirection: 'left' | 'right') => { + const curSelection = change.value.selection; + const selectedBlocks = change.value.document.getBlocksAtRange(curSelection); + + if (indentDirection === 'left') { + for (const block of selectedBlocks) { + const blockWhitespace = block.text.length - block.text.trimLeft().length; + + const rangeProperties = { + anchorKey: block.getFirstText().key, + anchorOffset: blockWhitespace, + focusKey: block.getFirstText().key, + focusOffset: blockWhitespace, + }; + + // @ts-ignore + const whitespaceToDelete = Range.create(rangeProperties); + + change.deleteBackwardAtRange(whitespaceToDelete, Math.min(SLATE_TAB.length, blockWhitespace)); + } + } else { + const { startText } = change.value; + const textBeforeCaret = startText.text.slice(0, curSelection.startOffset); + const isWhiteSpace = /^\s*$/.test(textBeforeCaret); + + for (const block of selectedBlocks) { + change.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB); + } + + if (isWhiteSpace) { + change.moveStart(-SLATE_TAB.length); + } + } + }; + + handleSelectVertical = (change: Change, direction: 'up' | 'down') => { + const { focusBlock } = change.value; + const adjacentBlock = + direction === 'up' + ? change.value.document.getPreviousBlock(focusBlock.key) + : change.value.document.getNextBlock(focusBlock.key); + + if (!adjacentBlock) { + return true; + } + const adjacentText = adjacentBlock.getFirstText(); + change.moveFocusTo(adjacentText.key, Math.min(change.value.anchorOffset, adjacentText.text.length)).focus(); + return true; + }; + + handleSelectUp = (change: Change) => this.handleSelectVertical(change, 'up'); + + handleSelectDown = (change: Change) => this.handleSelectVertical(change, 'down'); + + onKeyDown = (event: KeyboardEvent, change: Change) => { + const { typeaheadIndex } = this.state; + + // Shortcuts + if (isIndentLeftHotkey(event)) { + event.preventDefault(); + this.handleIndent(change, 'left'); + return true; + } else if (isIndentRightHotkey(event)) { + event.preventDefault(); + this.handleIndent(change, 'right'); + return true; + } else if (isSelectLeftHotkey(event)) { + event.preventDefault(); + if (change.value.focusOffset > 0) { + change.moveFocus(-1); + } + return true; + } else if (isSelectRightHotkey(event)) { + event.preventDefault(); + if (change.value.focusOffset < change.value.startText.text.length) { + change.moveFocus(1); + } + return true; + } else if (isSelectUpHotkey(event)) { + event.preventDefault(); + this.handleSelectUp(change); + return true; + } else if (isSelectDownHotkey(event)) { + event.preventDefault(); + this.handleSelectDown(change); + return true; + } else if (isSelectLineHotkey(event)) { + event.preventDefault(); + const { focusBlock, document } = change.value; + + change.moveAnchorToStartOfBlock(focusBlock.key); + + const nextBlock = document.getNextBlock(focusBlock.key); + if (nextBlock) { + change.moveFocusToStartOfNextBlock(); + } else { + change.moveFocusToEndOfText(); + } + + return true; + } + + switch (event.key) { + case 'Escape': { + if (this.menuEl) { + event.preventDefault(); + event.stopPropagation(); + this.resetTypeahead(); + return true; + } + break; + } + + case ' ': { + if (event.ctrlKey) { + event.preventDefault(); + this.handleTypeahead(); + return true; + } + break; + } + + case 'Enter': + return this.handleEnterKey(event, change); + + case 'Tab': { + event.preventDefault(); + return this.handleTabKey(change); + } + + case 'ArrowDown': { + if (this.menuEl) { + // Select next suggestion + event.preventDefault(); + const itemsCount = + this.state.suggestions.length > 0 + ? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0) + : 0; + this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) }); + } + break; + } + + case 'ArrowUp': { + if (this.menuEl) { + // Select previous suggestion + event.preventDefault(); + this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); + } + break; + } + + default: { + // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key); + break; + } + } + return undefined; + }; + + resetTypeahead = () => { + if (this.mounted) { + this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null }); + this.resetTimer = null; + } + }; + + handleBlur = (event: FocusEvent, change: Change) => { const { lastExecutedValue } = this.state; const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null; - const currentValue = Plain.serialize(editor.value); + const currentValue = Plain.serialize(change.value); + + // If we dont wait here, menu clicks wont work because the menu + // will be gone. + this.resetTimer = setTimeout(this.resetTypeahead, 100); if (previousValue !== currentValue) { this.executeOnChangeAndRunQueries(); } + }; + + onClickMenu = (item: CompletionItem) => { + // Manually triggering change + const change = this.applyTypeahead(this.state.value.change(), item); + this.onChange(change, true); + }; + + updateMenu = () => { + const { suggestions } = this.state; + const menu = this.menuEl; + // Exit for unit tests + if (!window.getSelection) { + return; + } + const selection = window.getSelection(); + const node = selection.anchorNode; + + // No menu, nothing to do + if (!menu) { + return; + } + + // No suggestions or blur, remove menu + if (!hasSuggestions(suggestions)) { + menu.removeAttribute('style'); + return; + } + + // Align menu overlay to editor node + if (node) { + // Read from DOM + const rect = node.parentElement.getBoundingClientRect(); + const scrollX = window.scrollX; + const scrollY = window.scrollY; + + // Write DOM + requestAnimationFrame(() => { + menu.style.opacity = '1'; + menu.style.top = `${rect.top + scrollY + rect.height + 4}px`; + menu.style.left = `${rect.left + scrollX - 2}px`; + }); + } + }; + + menuRef = (el: HTMLElement) => { + this.menuEl = el; + }; + + renderMenu = () => { + const { portalOrigin } = this.props; + const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state; + if (!hasSuggestions(suggestions)) { + return null; + } + + const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex); + + // Create typeahead in DOM root so we can later position it absolutely + return ( + + + + ); + }; + + getCopiedText(textBlocks: string[], startOffset: number, endOffset: number) { + if (!textBlocks.length) { + return undefined; + } + + const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1; + return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset); + } + + handleCopy = (event: ClipboardEvent, change: Change) => { + event.preventDefault(); + + const { document, selection, startOffset, endOffset } = change.value; + const selectedBlocks = document.getBlocksAtRangeAsArray(selection).map((block: Block) => block.text); + + const copiedText = this.getCopiedText(selectedBlocks, startOffset, endOffset); + if (copiedText) { + event.clipboardData.setData('Text', copiedText); + } + + return true; + }; + + handlePaste = (event: ClipboardEvent, change: Change) => { + event.preventDefault(); + const pastedValue = event.clipboardData.getData('Text'); + const lines = pastedValue.split('\n'); + + if (lines.length) { + change.insertText(lines[0]); + for (const line of lines.slice(1)) { + change.splitBlock().insertText(line); + } + } + + return true; + }; - editor.blur(); + handleCut = (event: ClipboardEvent, change: Change) => { + this.handleCopy(event, change); + change.deleteAtRange(change.value.selection); - return next(); + return true; }; render() { @@ -187,20 +670,19 @@ export class QueryField extends React.PureComponent
    + {this.renderMenu()} (this.editor = editor)} - schema={SCHEMA} autoCorrect={false} readOnly={this.props.disabled} onBlur={this.handleBlur} - // onKeyDown={this.onKeyDown} - onChange={(change: { value: Value }) => { - this.onChange(change.value, false); - }} + onKeyDown={this.onKeyDown} + onChange={this.onChange} + onCopy={this.handleCopy} + onPaste={this.handlePaste} + onCut={this.handleCut} placeholder={this.props.placeholder} plugins={this.plugins} spellCheck={false} @@ -212,4 +694,29 @@ export class QueryField extends React.PureComponent { + node: HTMLElement; + + constructor(props: PortalProps) { + super(props); + const { index = 0, origin = 'query' } = props; + this.node = document.createElement('div'); + this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`); + document.body.appendChild(this.node); + } + + componentWillUnmount() { + document.body.removeChild(this.node); + } + + render() { + return ReactDOM.createPortal(this.props.children, this.node); + } +} + export default QueryField; diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index 91e675d7eb8b..b28ab4a610d3 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/public/app/features/explore/Typeahead.tsx @@ -1,24 +1,21 @@ -import React, { createRef, CSSProperties } from 'react'; -import ReactDOM from 'react-dom'; +import React, { createRef } from 'react'; import _ from 'lodash'; import { FixedSizeList } from 'react-window'; import { Themeable, withTheme } from '@grafana/ui'; -import { CompletionItem, CompletionItemKind, CompletionItemGroup } from 'app/types/explore'; +import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; import { TypeaheadItem } from './TypeaheadItem'; import { TypeaheadInfo } from './TypeaheadInfo'; import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead'; -const modulo = (a: number, n: number) => a - n * Math.floor(a / n); - interface Props extends Themeable { - origin: string; groupedItems: CompletionItemGroup[]; + menuRef: any; + selectedItem: CompletionItem | null; + onClickItem: (suggestion: CompletionItem) => void; prefix?: string; - menuRef?: (el: Typeahead) => void; - onSelectSuggestion?: (suggestion: CompletionItem) => void; - isOpen?: boolean; + typeaheadIndex: number; } interface State { @@ -26,12 +23,11 @@ interface State { listWidth: number; listHeight: number; itemHeight: number; - hoveredItem: number; - typeaheadIndex: number; } export class Typeahead extends React.PureComponent { - listRef = createRef(); + listRef: any = createRef(); + documentationRef: any = createRef(); constructor(props: Props) { super(props); @@ -39,173 +35,97 @@ export class Typeahead extends React.PureComponent { const allItems = flattenGroupItems(props.groupedItems); const longestLabel = calculateLongestLabel(allItems); const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel); - this.state = { listWidth, listHeight, itemHeight, hoveredItem: null, typeaheadIndex: 1, allItems }; + this.state = { listWidth, listHeight, itemHeight, allItems }; } - componentDidMount = () => { - this.props.menuRef(this); - }; - - componentDidUpdate = (prevProps: Readonly, prevState: Readonly) => { - if (prevState.typeaheadIndex !== this.state.typeaheadIndex && this.listRef && this.listRef.current) { - if (this.state.typeaheadIndex === 1) { + componentDidUpdate = (prevProps: Readonly) => { + if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) { + if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) { this.listRef.current.scrollToItem(0); // special case for handling the first group label + this.refreshDocumentation(); return; } - this.listRef.current.scrollToItem(this.state.typeaheadIndex); + const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); + this.listRef.current.scrollToItem(index); + this.refreshDocumentation(); } if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) { const allItems = flattenGroupItems(this.props.groupedItems); const longestLabel = calculateLongestLabel(allItems); const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel); - this.setState({ listWidth, listHeight, itemHeight, allItems }); + this.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation()); } }; - onMouseEnter = (index: number) => { - this.setState({ - hoveredItem: index, - }); - }; - - onMouseLeave = () => { - this.setState({ - hoveredItem: null, - }); - }; - - moveMenuIndex = (moveAmount: number) => { - const itemCount = this.state.allItems.length; - if (itemCount) { - // Select next suggestion - event.preventDefault(); - let newTypeaheadIndex = modulo(this.state.typeaheadIndex + moveAmount, itemCount); - - if (this.state.allItems[newTypeaheadIndex].kind === CompletionItemKind.GroupTitle) { - newTypeaheadIndex = modulo(newTypeaheadIndex + moveAmount, itemCount); - } - - this.setState({ - typeaheadIndex: newTypeaheadIndex, - }); - + refreshDocumentation = () => { + if (!this.documentationRef.current) { return; } - }; - insertSuggestion = () => { - this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]); - }; + const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); + const item = this.state.allItems[index]; - get menuPosition(): CSSProperties { - // Exit for unit tests - if (!window.getSelection) { - return {}; + if (item) { + this.documentationRef.current.refresh(item); } + }; - const selection = window.getSelection(); - const node = selection.anchorNode; - - // Align menu overlay to editor node - if (node) { - // Read from DOM - const rect = node.parentElement.getBoundingClientRect(); - const scrollX = window.scrollX; - const scrollY = window.scrollY; - - return { - top: `${rect.top + scrollY + rect.height + 4}px`, - left: `${rect.left + scrollX - 2}px`, - }; - } + onMouseEnter = (item: CompletionItem) => { + this.documentationRef.current.refresh(item); + }; - return {}; - } + onMouseLeave = () => { + this.documentationRef.current.hide(); + }; render() { - const { prefix, theme, isOpen, origin } = this.props; - const { allItems, listWidth, listHeight, itemHeight, hoveredItem, typeaheadIndex } = this.state; - - const showDocumentation = hoveredItem || typeaheadIndex; + const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props; + const { listWidth, listHeight, itemHeight, allItems } = this.state; return ( - -
      - { - const item = allItems && allItems[index]; - const key = item ? `${index}-${item.label}` : `${index}`; - return key; - }} - width={listWidth} - height={listHeight} - > - {({ index, style }) => { - const item = allItems && allItems[index]; - if (!item) { - return null; - } - - return ( - this.props.onSelectSuggestion(item)} - isSelected={allItems[typeaheadIndex] === item} - item={item} - prefix={prefix} - style={style} - onMouseEnter={() => this.onMouseEnter(index)} - onMouseLeave={this.onMouseLeave} - /> - ); - }} - -
    - - {showDocumentation && ( - - )} -
    +
      + + { + const item = allItems && allItems[index]; + const key = item ? `${index}-${item.label}` : `${index}`; + return key; + }} + width={listWidth} + height={listHeight} + > + {({ index, style }) => { + const item = allItems && allItems[index]; + if (!item) { + return null; + } + + return ( + + ); + }} + +
    ); } } export const TypeaheadWithTheme = withTheme(Typeahead); - -interface PortalProps { - index?: number; - isOpen: boolean; - origin: string; -} - -class Portal extends React.PureComponent { - node: HTMLElement; - - constructor(props: PortalProps) { - super(props); - const { index = 0, origin = 'query' } = props; - this.node = document.createElement('div'); - this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`); - document.body.appendChild(this.node); - } - - componentWillUnmount() { - document.body.removeChild(this.node); - } - - render() { - if (this.props.isOpen) { - return ReactDOM.createPortal(this.props.children, this.node); - } - - return null; - } -} diff --git a/public/app/features/explore/TypeaheadInfo.tsx b/public/app/features/explore/TypeaheadInfo.tsx index f18edcee17db..4b410c8b3659 100644 --- a/public/app/features/explore/TypeaheadInfo.tsx +++ b/public/app/features/explore/TypeaheadInfo.tsx @@ -1,26 +1,29 @@ import React, { PureComponent } from 'react'; -import { css, cx } from 'emotion'; - import { Themeable, selectThemeVariant } from '@grafana/ui'; +import { css, cx } from 'emotion'; import { CompletionItem } from 'app/types/explore'; interface Props extends Themeable { - item: CompletionItem; + initialItem: CompletionItem; width: number; height: number; } -export class TypeaheadInfo extends PureComponent { +interface State { + item: CompletionItem; +} + +export class TypeaheadInfo extends PureComponent { constructor(props: Props) { super(props); + this.state = { item: props.initialItem }; } getStyles = (visible: boolean) => { const { width, height, theme } = this.props; const selection = window.getSelection(); const node = selection.anchorNode; - if (!node) { return {}; } @@ -35,7 +38,7 @@ export class TypeaheadInfo extends PureComponent { return { typeaheadItem: css` label: type-ahead-item; - z-index: 500; + z-index: auto; padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md}; border-radius: ${theme.border.radius.md}; border: ${selectThemeVariant( @@ -61,8 +64,16 @@ export class TypeaheadInfo extends PureComponent { }; }; + refresh = (item: CompletionItem) => { + this.setState({ item }); + }; + + hide = () => { + this.setState({ item: null }); + }; + render() { - const { item } = this.props; + const { item } = this.state; const visible = item && !!item.documentation; const label = item ? item.label : ''; const documentation = item && item.documentation ? item.documentation : ''; diff --git a/public/app/features/explore/TypeaheadItem.tsx b/public/app/features/explore/TypeaheadItem.tsx index e20a57586137..f670330e44de 100644 --- a/public/app/features/explore/TypeaheadItem.tsx +++ b/public/app/features/explore/TypeaheadItem.tsx @@ -1,21 +1,25 @@ import React, { FunctionComponent, useContext } from 'react'; - // @ts-ignore import Highlighter from 'react-highlight-words'; import { css, cx } from 'emotion'; import { GrafanaTheme, ThemeContext, selectThemeVariant } from '@grafana/ui'; -import { CompletionItem, CompletionItemKind } from 'app/types/explore'; +import { CompletionItem } from 'app/types/explore'; + +export const GROUP_TITLE_KIND = 'GroupTitle'; + +export const isGroupTitle = (item: CompletionItem) => { + return item.kind && item.kind === GROUP_TITLE_KIND ? true : false; +}; interface Props { isSelected: boolean; item: CompletionItem; - style: any; + onClickItem: (suggestion: CompletionItem) => void; prefix?: string; - - onClickItem?: (event: React.MouseEvent) => void; - onMouseEnter?: () => void; - onMouseLeave?: () => void; + style: any; + onMouseEnter: (item: CompletionItem) => void; + onMouseLeave: (item: CompletionItem) => void; } const getStyles = (theme: GrafanaTheme) => ({ @@ -34,12 +38,10 @@ const getStyles = (theme: GrafanaTheme) => ({ transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1); `, - typeaheadItemSelected: css` label: type-ahead-item-selected; background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.dark9 }, theme.type)}; `, - typeaheadItemMatch: css` label: type-ahead-item-match; color: ${theme.colors.yellow}; @@ -47,7 +49,6 @@ const getStyles = (theme: GrafanaTheme) => ({ padding: inherit; background: inherit; `, - typeaheadItemGroupTitle: css` label: type-ahead-item-group-title; color: ${theme.colors.textWeak}; @@ -61,13 +62,16 @@ export const TypeaheadItem: FunctionComponent = (props: Props) => { const theme = useContext(ThemeContext); const styles = getStyles(theme); - const { isSelected, item, prefix, style, onMouseEnter, onMouseLeave, onClickItem } = props; + const { isSelected, item, prefix, style, onClickItem } = props; + const onClick = () => onClickItem(item); + const onMouseEnter = () => props.onMouseEnter(item); + const onMouseLeave = () => props.onMouseLeave(item); const className = isSelected ? cx([styles.typeaheadItem, styles.typeaheadItemSelected]) : cx([styles.typeaheadItem]); const highlightClassName = cx([styles.typeaheadItemMatch]); const itemGroupTitleClassName = cx([styles.typeaheadItemGroupTitle]); const label = item.label || ''; - if (item.kind === CompletionItemKind.GroupTitle) { + if (isGroupTitle(item)) { return (
  • {label} @@ -76,13 +80,7 @@ export const TypeaheadItem: FunctionComponent = (props: Props) => { } return ( -
  • +
  • ); diff --git a/public/app/features/explore/slate-plugins/braces.test.ts b/public/app/features/explore/slate-plugins/braces.test.ts new file mode 100644 index 000000000000..d72ea0f3d974 --- /dev/null +++ b/public/app/features/explore/slate-plugins/braces.test.ts @@ -0,0 +1,39 @@ +// @ts-ignore +import Plain from 'slate-plain-serializer'; + +import BracesPlugin from './braces'; + +declare global { + interface Window { + KeyboardEvent: any; + } +} + +describe('braces', () => { + const handler = BracesPlugin().onKeyDown; + + it('adds closing braces around empty value', () => { + const change = Plain.deserialize('').change(); + const event = new window.KeyboardEvent('keydown', { key: '(' }); + handler(event, change); + expect(Plain.serialize(change.value)).toEqual('()'); + }); + + it('removes closing brace when opening brace is removed', () => { + const change = Plain.deserialize('time()').change(); + let event; + change.move(5); + event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); + handler(event, change); + expect(Plain.serialize(change.value)).toEqual('time'); + }); + + it('keeps closing brace when opening brace is removed and inner values exist', () => { + const change = Plain.deserialize('time(value)').change(); + let event; + change.move(5); + event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); + const handled = handler(event, change); + expect(handled).toBeFalsy(); + }); +}); diff --git a/public/app/features/explore/slate-plugins/braces.test.tsx b/public/app/features/explore/slate-plugins/braces.test.tsx deleted file mode 100644 index a80f67c817f1..000000000000 --- a/public/app/features/explore/slate-plugins/braces.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import Plain from 'slate-plain-serializer'; -import { Editor } from '@grafana/slate-react'; -import { shallow } from 'enzyme'; -import BracesPlugin from './braces'; - -declare global { - interface Window { - KeyboardEvent: any; - } -} - -describe('braces', () => { - const handler = BracesPlugin().onKeyDown; - const nextMock = () => {}; - - it('adds closing braces around empty value', () => { - const value = Plain.deserialize(''); - const editor = shallow(); - const event = new window.KeyboardEvent('keydown', { key: '(' }); - handler(event as Event, editor.instance() as any, nextMock); - expect(Plain.serialize(editor.instance().value)).toEqual('()'); - }); - - it('removes closing brace when opening brace is removed', () => { - const value = Plain.deserialize('time()'); - const editor = shallow(); - const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); - handler(event as Event, editor.instance().moveForward(5) as any, nextMock); - expect(Plain.serialize(editor.instance().value)).toEqual('time'); - }); - - it('keeps closing brace when opening brace is removed and inner values exist', () => { - const value = Plain.deserialize('time(value)'); - const editor = shallow(); - const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); - const handled = handler(event as Event, editor.instance().moveForward(5) as any, nextMock); - expect(handled).toBeFalsy(); - }); -}); diff --git a/public/app/features/explore/slate-plugins/braces.ts b/public/app/features/explore/slate-plugins/braces.ts index 0eff1fa7e4f1..ee6227cc309c 100644 --- a/public/app/features/explore/slate-plugins/braces.ts +++ b/public/app/features/explore/slate-plugins/braces.ts @@ -1,5 +1,5 @@ -import { Plugin } from '@grafana/slate-react'; -import { Editor as CoreEditor } from 'slate'; +// @ts-ignore +import { Change } from 'slate'; const BRACES: any = { '[': ']', @@ -7,37 +7,34 @@ const BRACES: any = { '(': ')', }; -export default function BracesPlugin(): Plugin { +export default function BracesPlugin() { return { - onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { - const { value } = editor; + onKeyDown(event: KeyboardEvent, change: Change) { + const { value } = change; switch (event.key) { case '(': case '{': case '[': { event.preventDefault(); - const { - start: { offset: startOffset, key: startKey }, - end: { offset: endOffset, key: endKey }, - focus: { offset: focusOffset }, - } = value.selection; - const text = value.focusText.text; + + const { startOffset, startKey, endOffset, endKey, focusOffset } = value.selection; + const text: string = value.focusText.text; // If text is selected, wrap selected text in parens - if (value.selection.isExpanded) { - editor + if (value.isExpanded) { + change .insertTextByKey(startKey, startOffset, event.key) .insertTextByKey(endKey, endOffset + 1, BRACES[event.key]) - .moveEndBackward(1); + .moveEnd(-1); } else if ( focusOffset === text.length || text[focusOffset] === ' ' || Object.values(BRACES).includes(text[focusOffset]) ) { - editor.insertText(`${event.key}${BRACES[event.key]}`).moveBackward(1); + change.insertText(`${event.key}${BRACES[event.key]}`).move(-1); } else { - editor.insertText(event.key); + change.insertText(event.key); } return true; @@ -45,15 +42,15 @@ export default function BracesPlugin(): Plugin { case 'Backspace': { const text = value.anchorText.text; - const offset = value.selection.anchor.offset; + const offset = value.anchorOffset; const previousChar = text[offset - 1]; const nextChar = text[offset]; if (BRACES[previousChar] && BRACES[previousChar] === nextChar) { event.preventDefault(); // Remove closing brace if directly following - editor - .deleteBackward(1) - .deleteForward(1) + change + .deleteBackward() + .deleteForward() .focus(); return true; } @@ -63,8 +60,7 @@ export default function BracesPlugin(): Plugin { break; } } - - return next(); + return undefined; }, }; } diff --git a/public/app/features/explore/slate-plugins/clear.test.ts b/public/app/features/explore/slate-plugins/clear.test.ts new file mode 100644 index 000000000000..9322fffd7d2d --- /dev/null +++ b/public/app/features/explore/slate-plugins/clear.test.ts @@ -0,0 +1,39 @@ +// @ts-ignore +import Plain from 'slate-plain-serializer'; + +import ClearPlugin from './clear'; + +describe('clear', () => { + const handler = ClearPlugin().onKeyDown; + + it('does not change the empty value', () => { + const change = Plain.deserialize('').change(); + const event = new window.KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + }); + handler(event, change); + expect(Plain.serialize(change.value)).toEqual(''); + }); + + it('clears to the end of the line', () => { + const change = Plain.deserialize('foo').change(); + const event = new window.KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + }); + handler(event, change); + expect(Plain.serialize(change.value)).toEqual(''); + }); + + it('clears from the middle to the end of the line', () => { + const change = Plain.deserialize('foo bar').change(); + change.move(4); + const event = new window.KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + }); + handler(event, change); + expect(Plain.serialize(change.value)).toEqual('foo '); + }); +}); diff --git a/public/app/features/explore/slate-plugins/clear.test.tsx b/public/app/features/explore/slate-plugins/clear.test.tsx deleted file mode 100644 index 4565827e3859..000000000000 --- a/public/app/features/explore/slate-plugins/clear.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Plain from 'slate-plain-serializer'; -import React from 'react'; -import { Editor } from '@grafana/slate-react'; -import { shallow } from 'enzyme'; -import ClearPlugin from './clear'; - -describe('clear', () => { - const handler = ClearPlugin().onKeyDown; - - it('does not change the empty value', () => { - const value = Plain.deserialize(''); - const editor = shallow(); - const event = new window.KeyboardEvent('keydown', { - key: 'k', - ctrlKey: true, - }); - handler(event as Event, editor.instance() as any, () => {}); - expect(Plain.serialize(editor.instance().value)).toEqual(''); - }); - - it('clears to the end of the line', () => { - const value = Plain.deserialize('foo'); - const editor = shallow(); - const event = new window.KeyboardEvent('keydown', { - key: 'k', - ctrlKey: true, - }); - handler(event as Event, editor.instance() as any, () => {}); - expect(Plain.serialize(editor.instance().value)).toEqual(''); - }); - - it('clears from the middle to the end of the line', () => { - const value = Plain.deserialize('foo bar'); - const editor = shallow(); - const event = new window.KeyboardEvent('keydown', { - key: 'k', - ctrlKey: true, - }); - handler(event as Event, editor.instance().moveForward(4) as any, () => {}); - expect(Plain.serialize(editor.instance().value)).toEqual('foo '); - }); -}); diff --git a/public/app/features/explore/slate-plugins/clear.ts b/public/app/features/explore/slate-plugins/clear.ts index 83dcf2e27b7b..9d649aa69260 100644 --- a/public/app/features/explore/slate-plugins/clear.ts +++ b/public/app/features/explore/slate-plugins/clear.ts @@ -1,27 +1,22 @@ -import { Plugin } from '@grafana/slate-react'; -import { Editor as CoreEditor } from 'slate'; - // Clears the rest of the line after the caret -export default function ClearPlugin(): Plugin { +export default function ClearPlugin() { return { - onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { - const value = editor.value; - - if (value.selection.isExpanded) { - return next(); + onKeyDown(event: any, change: { value?: any; deleteForward?: any }) { + const { value } = change; + if (!value.isCollapsed) { + return undefined; } if (event.key === 'k' && event.ctrlKey) { event.preventDefault(); const text = value.anchorText.text; - const offset = value.selection.anchor.offset; + const offset = value.anchorOffset; const length = text.length; const forward = length - offset; - editor.deleteForward(forward); + change.deleteForward(forward); return true; } - - return next(); + return undefined; }, }; } diff --git a/public/app/features/explore/slate-plugins/clipboard.ts b/public/app/features/explore/slate-plugins/clipboard.ts deleted file mode 100644 index 79d277ec65a6..000000000000 --- a/public/app/features/explore/slate-plugins/clipboard.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Plugin } from '@grafana/slate-react'; -import { Editor as CoreEditor } from 'slate'; - -const getCopiedText = (textBlocks: string[], startOffset: number, endOffset: number) => { - if (!textBlocks.length) { - return undefined; - } - - const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1; - return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset); -}; - -export default function ClipboardPlugin(): Plugin { - const clipboardPlugin = { - onCopy(event: ClipboardEvent, editor: CoreEditor) { - event.preventDefault(); - - const { document, selection } = editor.value; - const { - start: { offset: startOffset }, - end: { offset: endOffset }, - } = selection; - const selectedBlocks = document - .getLeafBlocksAtRange(selection) - .toArray() - .map(block => block.text); - - const copiedText = getCopiedText(selectedBlocks, startOffset, endOffset); - if (copiedText) { - event.clipboardData.setData('Text', copiedText); - } - - return true; - }, - - onPaste(event: ClipboardEvent, editor: CoreEditor) { - event.preventDefault(); - const pastedValue = event.clipboardData.getData('Text'); - const lines = pastedValue.split('\n'); - - if (lines.length) { - editor.insertText(lines[0]); - for (const line of lines.slice(1)) { - editor.splitBlock().insertText(line); - } - } - - return true; - }, - }; - - return { - ...clipboardPlugin, - onCut(event: ClipboardEvent, editor: CoreEditor) { - clipboardPlugin.onCopy(event, editor); - editor.deleteAtRange(editor.value.selection); - - return true; - }, - }; -} diff --git a/public/app/features/explore/slate-plugins/indentation.ts b/public/app/features/explore/slate-plugins/indentation.ts deleted file mode 100644 index d3f1ab154c36..000000000000 --- a/public/app/features/explore/slate-plugins/indentation.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { RangeJSON, Range as SlateRange, Editor as CoreEditor } from 'slate'; -import { Plugin } from '@grafana/slate-react'; -import { isKeyHotkey } from 'is-hotkey'; - -const isIndentLeftHotkey = isKeyHotkey('mod+['); -const isShiftTabHotkey = isKeyHotkey('shift+tab'); -const isIndentRightHotkey = isKeyHotkey('mod+]'); - -const SLATE_TAB = ' '; - -const handleTabKey = (event: KeyboardEvent, editor: CoreEditor, next: Function): void => { - const { - startBlock, - endBlock, - selection: { - start: { offset: startOffset, key: startKey }, - end: { offset: endOffset, key: endKey }, - }, - } = editor.value; - - const first = startBlock.getFirstText(); - - const startBlockIsSelected = - startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key; - - if (startBlockIsSelected || !startBlock.equals(endBlock)) { - handleIndent(editor, 'right'); - } else { - editor.insertText(SLATE_TAB); - } -}; - -const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') => { - const curSelection = editor.value.selection; - const selectedBlocks = editor.value.document.getLeafBlocksAtRange(curSelection).toArray(); - - if (indentDirection === 'left') { - for (const block of selectedBlocks) { - const blockWhitespace = block.text.length - block.text.trimLeft().length; - - const textKey = block.getFirstText().key; - - const rangeProperties: RangeJSON = { - anchor: { - key: textKey, - offset: blockWhitespace, - path: [], - }, - focus: { - key: textKey, - offset: blockWhitespace, - path: [], - }, - }; - - editor.deleteBackwardAtRange(SlateRange.create(rangeProperties), Math.min(SLATE_TAB.length, blockWhitespace)); - } - } else { - const { startText } = editor.value; - const textBeforeCaret = startText.text.slice(0, curSelection.start.offset); - const isWhiteSpace = /^\s*$/.test(textBeforeCaret); - - for (const block of selectedBlocks) { - editor.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB); - } - - if (isWhiteSpace) { - editor.moveStartBackward(SLATE_TAB.length); - } - } -}; - -// Clears the rest of the line after the caret -export default function IndentationPlugin(): Plugin { - return { - onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { - if (isIndentLeftHotkey(event) || isShiftTabHotkey(event)) { - event.preventDefault(); - handleIndent(editor, 'left'); - } else if (isIndentRightHotkey(event)) { - event.preventDefault(); - handleIndent(editor, 'right'); - } else if (event.key === 'Tab') { - event.preventDefault(); - handleTabKey(event, editor, next); - } else { - return next(); - } - - return true; - }, - }; -} diff --git a/public/app/features/explore/slate-plugins/newline.ts b/public/app/features/explore/slate-plugins/newline.ts index c31d2a74b18a..a20bb162870d 100644 --- a/public/app/features/explore/slate-plugins/newline.ts +++ b/public/app/features/explore/slate-plugins/newline.ts @@ -1,7 +1,7 @@ -import { Plugin } from '@grafana/slate-react'; -import { Editor as CoreEditor } from 'slate'; +// @ts-ignore +import { Change } from 'slate'; -function getIndent(text: string) { +function getIndent(text: any) { let offset = text.length - text.trimLeft().length; if (offset) { let indent = text[0]; @@ -13,13 +13,12 @@ function getIndent(text: string) { return ''; } -export default function NewlinePlugin(): Plugin { +export default function NewlinePlugin() { return { - onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { - const value = editor.value; - - if (value.selection.isExpanded) { - return next(); + onKeyDown(event: KeyboardEvent, change: Change) { + const { value } = change; + if (!value.isCollapsed) { + return undefined; } if (event.key === 'Enter' && event.shiftKey) { @@ -29,13 +28,11 @@ export default function NewlinePlugin(): Plugin { const currentLineText = startBlock.text; const indent = getIndent(currentLineText); - return editor + return change .splitBlock() .insertText(indent) .focus(); } - - return next(); }, }; } diff --git a/public/app/features/explore/slate-plugins/runner.test.tsx b/public/app/features/explore/slate-plugins/runner.test.tsx deleted file mode 100644 index 3604681e03a2..000000000000 --- a/public/app/features/explore/slate-plugins/runner.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Plain from 'slate-plain-serializer'; -import React from 'react'; -import { Editor } from '@grafana/slate-react'; -import { shallow } from 'enzyme'; -import RunnerPlugin from './runner'; - -describe('runner', () => { - const mockHandler = jest.fn(); - const handler = RunnerPlugin({ handler: mockHandler }).onKeyDown; - - it('should execute query when enter is pressed and there are no suggestions visible', () => { - const value = Plain.deserialize(''); - const editor = shallow(); - handler({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, editor.instance() as any, () => {}); - expect(mockHandler).toBeCalled(); - }); -}); diff --git a/public/app/features/explore/slate-plugins/runner.ts b/public/app/features/explore/slate-plugins/runner.ts index bb3a10f87590..fc7b8a778ed0 100644 --- a/public/app/features/explore/slate-plugins/runner.ts +++ b/public/app/features/explore/slate-plugins/runner.ts @@ -1,8 +1,6 @@ -import { Editor as SlateEditor } from 'slate'; - export default function RunnerPlugin({ handler }: any) { return { - onKeyDown(event: KeyboardEvent, editor: SlateEditor, next: Function) { + onKeyDown(event: any) { // Handle enter if (handler && event.key === 'Enter' && !event.shiftKey) { // Submit on Enter @@ -10,8 +8,7 @@ export default function RunnerPlugin({ handler }: any) { handler(event); return true; } - - return next(); + return undefined; }, }; } diff --git a/public/app/features/explore/slate-plugins/selection_shortcuts.ts b/public/app/features/explore/slate-plugins/selection_shortcuts.ts deleted file mode 100644 index d0849d34f040..000000000000 --- a/public/app/features/explore/slate-plugins/selection_shortcuts.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Plugin } from '@grafana/slate-react'; -import { Editor as CoreEditor } from 'slate'; - -import { isKeyHotkey } from 'is-hotkey'; - -const isSelectLeftHotkey = isKeyHotkey('shift+left'); -const isSelectRightHotkey = isKeyHotkey('shift+right'); -const isSelectUpHotkey = isKeyHotkey('shift+up'); -const isSelectDownHotkey = isKeyHotkey('shift+down'); -const isSelectLineHotkey = isKeyHotkey('mod+l'); - -const handleSelectVertical = (editor: CoreEditor, direction: 'up' | 'down') => { - const { focusBlock } = editor.value; - const adjacentBlock = - direction === 'up' - ? editor.value.document.getPreviousBlock(focusBlock.key) - : editor.value.document.getNextBlock(focusBlock.key); - - if (!adjacentBlock) { - return true; - } - const adjacentText = adjacentBlock.getFirstText(); - editor - .moveFocusTo(adjacentText.key, Math.min(editor.value.selection.anchor.offset, adjacentText.text.length)) - .focus(); - return true; -}; - -const handleSelectUp = (editor: CoreEditor) => handleSelectVertical(editor, 'up'); - -const handleSelectDown = (editor: CoreEditor) => handleSelectVertical(editor, 'down'); - -// Clears the rest of the line after the caret -export default function SelectionShortcutsPlugin(): Plugin { - return { - onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { - if (isSelectLeftHotkey(event)) { - event.preventDefault(); - if (editor.value.selection.focus.offset > 0) { - editor.moveFocusBackward(1); - } - } else if (isSelectRightHotkey(event)) { - event.preventDefault(); - if (editor.value.selection.focus.offset < editor.value.startText.text.length) { - editor.moveFocusForward(1); - } - } else if (isSelectUpHotkey(event)) { - event.preventDefault(); - handleSelectUp(editor); - } else if (isSelectDownHotkey(event)) { - event.preventDefault(); - handleSelectDown(editor); - } else if (isSelectLineHotkey(event)) { - event.preventDefault(); - const { focusBlock, document } = editor.value; - - editor.moveAnchorToStartOfBlock(); - - const nextBlock = document.getNextBlock(focusBlock.key); - if (nextBlock) { - editor.moveFocusToStartOfNextBlock(); - } else { - editor.moveFocusToEndOfText(); - } - } else { - return next(); - } - - return true; - }, - }; -} diff --git a/public/app/features/explore/slate-plugins/suggestions.tsx b/public/app/features/explore/slate-plugins/suggestions.tsx deleted file mode 100644 index a3106ff5795e..000000000000 --- a/public/app/features/explore/slate-plugins/suggestions.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import React from 'react'; -import debounce from 'lodash/debounce'; -import sortBy from 'lodash/sortBy'; - -import { Editor as CoreEditor } from 'slate'; -import { Plugin as SlatePlugin } from '@grafana/slate-react'; -import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types'; - -import { QueryField, TypeaheadInput } from '../QueryField'; -import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK'; -import { TypeaheadWithTheme, Typeahead } from '../Typeahead'; - -import { makeFragment } from '@grafana/ui'; - -export const TYPEAHEAD_DEBOUNCE = 100; - -export interface SuggestionsState { - groupedItems: CompletionItemGroup[]; - typeaheadPrefix: string; - typeaheadContext: string; - typeaheadText: string; -} - -let state: SuggestionsState = { - groupedItems: [], - typeaheadPrefix: '', - typeaheadContext: '', - typeaheadText: '', -}; - -export default function SuggestionsPlugin({ - onTypeahead, - cleanText, - onWillApplySuggestion, - syntax, - portalOrigin, - component, -}: { - onTypeahead: (typeahead: TypeaheadInput) => Promise; - cleanText?: (text: string) => string; - onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; - syntax?: string; - portalOrigin: string; - component: QueryField; // Need to attach typeaheadRef here -}): SlatePlugin { - return { - onBlur: (event, editor, next) => { - state = { - ...state, - groupedItems: [], - }; - - return next(); - }, - - onClick: (event, editor, next) => { - state = { - ...state, - groupedItems: [], - }; - - return next(); - }, - - onKeyDown: (event: KeyboardEvent, editor, next) => { - const currentSuggestions = state.groupedItems; - - const hasSuggestions = currentSuggestions.length; - - switch (event.key) { - case 'Escape': { - if (hasSuggestions) { - event.preventDefault(); - - state = { - ...state, - groupedItems: [], - }; - - // Bogus edit to re-render editor - return editor.insertText(''); - } - - break; - } - - case 'ArrowDown': - case 'ArrowUp': - if (hasSuggestions) { - event.preventDefault(); - component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1); - return; - } - - break; - - case 'Enter': - case 'Tab': { - if (hasSuggestions) { - event.preventDefault(); - - component.typeaheadRef.insertSuggestion(); - return handleTypeahead(event, editor, next, onTypeahead, cleanText); - } - - break; - } - - default: { - handleTypeahead(event, editor, next, onTypeahead, cleanText); - break; - } - } - - return next(); - }, - - commands: { - selectSuggestion: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => { - const suggestions = state.groupedItems; - if (!suggestions || !suggestions.length) { - return editor; - } - - // @ts-ignore - return editor.applyTypeahead(suggestion); - }, - - applyTypeahead: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => { - let suggestionText = suggestion.insertText || suggestion.label; - - const preserveSuffix = suggestion.kind === 'function'; - const move = suggestion.move || 0; - - const { typeaheadPrefix, typeaheadText, typeaheadContext } = state; - - if (onWillApplySuggestion) { - suggestionText = onWillApplySuggestion(suggestionText, { - groupedItems: state.groupedItems, - typeaheadContext, - typeaheadPrefix, - typeaheadText, - }); - } - - // Remove the current, incomplete text and replace it with the selected suggestion - const backward = suggestion.deleteBackwards || typeaheadPrefix.length; - const text = cleanText ? cleanText(typeaheadText) : typeaheadText; - const suffixLength = text.length - typeaheadPrefix.length; - const offset = typeaheadText.indexOf(typeaheadPrefix); - const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); - const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; - - // If new-lines, apply suggestion as block - if (suggestionText.match(/\n/)) { - const fragment = makeFragment(suggestionText); - return editor - .deleteBackward(backward) - .deleteForward(forward) - .insertFragment(fragment) - .focus(); - } - - state = { - ...state, - groupedItems: [], - }; - - return editor - .deleteBackward(backward) - .deleteForward(forward) - .insertText(suggestionText) - .moveForward(move) - .focus(); - }, - }, - - renderEditor: (props, editor, next) => { - if (editor.value.selection.isExpanded) { - return next(); - } - - const children = next(); - - return ( - <> - {children} - (component.typeaheadRef = el)} - origin={portalOrigin} - prefix={state.typeaheadPrefix} - isOpen={!!state.groupedItems.length} - groupedItems={state.groupedItems} - //@ts-ignore - onSelectSuggestion={editor.selectSuggestion} - /> - - ); - }, - }; -} - -const handleTypeahead = debounce( - async ( - event: Event, - editor: CoreEditor, - next: () => {}, - onTypeahead?: (typeahead: TypeaheadInput) => Promise, - cleanText?: (text: string) => string - ) => { - if (!onTypeahead) { - return next(); - } - - const { value } = editor; - const { selection } = value; - - // Get decorations associated with the current line - const parentBlock = value.document.getClosestBlock(value.focusBlock.key); - const myOffset = value.selection.start.offset - 1; - const decorations = parentBlock.getDecorations(editor as any); - - const filteredDecorations = decorations - .filter( - decoration => - decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK - ) - .toArray(); - - const labelKeyDec = decorations - .filter( - decoration => - decoration.end.offset === myOffset && - decoration.type === TOKEN_MARK && - decoration.data.get('className').includes('label-key') - ) - .first(); - - const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset); - - const wrapperClasses = filteredDecorations - .map(decoration => decoration.data.get('className')) - .join(' ') - .split(' ') - .filter(className => className.length); - - let text = value.focusText.text; - let prefix = text.slice(0, selection.focus.offset); - - if (filteredDecorations.length) { - text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset); - prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset); - } - - // Label values could have valid characters erased if `cleanText()` is - // blindly applied, which would undesirably interfere with suggestions - const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); - if (labelValueMatch) { - prefix = labelValueMatch[1]; - } else if (cleanText) { - prefix = cleanText(prefix); - } - - const { suggestions, context } = await onTypeahead({ - prefix, - text, - value, - wrapperClasses, - labelKey, - }); - - const filteredSuggestions = suggestions - .map(group => { - if (!group.items) { - return group; - } - - if (prefix) { - // Filter groups based on prefix - if (!group.skipFilter) { - group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); - if (group.prefixMatch) { - group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix)); - } else { - group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix)); - } - } - - // Filter out the already typed value (prefix) unless it inserts custom text - group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); - } - - if (!group.skipSort) { - group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); - } - - return group; - }) - .filter(group => group.items && group.items.length); // Filter out empty groups - - state = { - ...state, - groupedItems: filteredSuggestions, - typeaheadPrefix: prefix, - typeaheadContext: context, - typeaheadText: text, - }; - - // Bogus edit to force re-render - return editor.insertText(''); - }, - TYPEAHEAD_DEBOUNCE -); diff --git a/public/app/features/explore/utils/typeahead.ts b/public/app/features/explore/utils/typeahead.ts index e501e2ab60a4..7de817e45787 100644 --- a/public/app/features/explore/utils/typeahead.ts +++ b/public/app/features/explore/utils/typeahead.ts @@ -1,13 +1,14 @@ import { GrafanaTheme } from '@grafana/ui'; import { default as calculateSize } from 'calculate-size'; -import { CompletionItemGroup, CompletionItem, CompletionItemKind } from 'app/types'; +import { CompletionItemGroup, CompletionItem } from 'app/types'; +import { GROUP_TITLE_KIND } from '../TypeaheadItem'; export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => { return groupedItems.reduce((all, current) => { const titleItem: CompletionItem = { label: current.label, - kind: CompletionItemKind.GroupTitle, + kind: GROUP_TITLE_KIND, }; return all.concat(titleItem, current.items); }, []); @@ -55,7 +56,8 @@ export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaThem export const calculateListHeight = (itemHeight: number, allItems: CompletionItem[]) => { const numberOfItemsToShow = Math.min(allItems.length, 10); const minHeight = 100; - const totalHeight = numberOfItemsToShow * itemHeight; + const itemsInView = allItems.slice(0, numberOfItemsToShow); + const totalHeight = itemsInView.length * itemHeight; const listHeight = Math.max(totalHeight, minHeight); return listHeight; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 2b7af6e32253..1425eeb32266 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -10,7 +10,7 @@ import jquery from 'jquery'; import prismjs from 'prismjs'; import slate from 'slate'; // @ts-ignore -import slateReact from '@grafana/slate-react'; +import slateReact from 'slate-react'; // @ts-ignore import slatePlain from 'slate-plain-serializer'; import react from 'react'; @@ -91,7 +91,7 @@ exposeToPlugin('rxjs', { // Experimental modules exposeToPlugin('prismjs', prismjs); exposeToPlugin('slate', slate); -exposeToPlugin('@grafana/slate-react', slateReact); +exposeToPlugin('slate-react', slateReact); exposeToPlugin('slate-plain-serializer', slatePlain); exposeToPlugin('react', react); exposeToPlugin('react-dom', reactDom); diff --git a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx b/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx index 98c01ba10573..ab6ef40272c8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx @@ -1,7 +1,9 @@ import _ from 'lodash'; import React from 'react'; - -import { SlatePrism } from '@grafana/ui'; +// @ts-ignore +import PluginPrism from 'slate-prism'; +// @ts-ignore +import Prism from 'prismjs'; // dom also includes Element polyfills import QueryField from 'app/features/explore/QueryField'; @@ -22,7 +24,7 @@ class ElasticsearchQueryField extends React.PureComponent { super(props, context); this.plugins = [ - SlatePrism({ + PluginPrism({ onlyIn: (node: any) => node.type === 'code_block', getSyntax: (node: any) => 'lucene', }), diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx index 9e8c4a3205c3..d3052691acc6 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx @@ -1,13 +1,12 @@ import _ from 'lodash'; +// @ts-ignore import Plain from 'slate-plain-serializer'; import QueryField from './query_field'; import debounce from 'lodash/debounce'; import { DOMUtil } from '@grafana/ui'; -import { Editor as SlateEditor } from 'slate'; import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto'; -import { CompletionItem } from 'app/types'; // import '../sass/editor.base.scss'; const TYPEAHEAD_DELAY = 100; @@ -64,7 +63,7 @@ export default class KustoQueryField extends QueryField { this.fetchSchema(); } - onTypeahead = (force = false) => { + onTypeahead = (force?: boolean) => { const selection = window.getSelection(); if (selection.anchorNode) { const wrapperNode = selection.anchorNode.parentElement; @@ -197,15 +196,15 @@ export default class KustoQueryField extends QueryField { } }; - applyTypeahead = (editor: SlateEditor, suggestion: CompletionItem): SlateEditor => { + applyTypeahead(change: any, suggestion: { text: any; type: string; deleteBackwards: any }) { const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state; - let suggestionText = suggestion.label; + let suggestionText = suggestion.text || suggestion; const move = 0; // Modify suggestion based on context const nextChar = DOMUtil.getNextCharacter(); - if (suggestion.kind === 'function') { + if (suggestion.type === 'function') { if (!nextChar || nextChar !== '(') { suggestionText += '('; } @@ -229,13 +228,13 @@ export default class KustoQueryField extends QueryField { const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); const forward = midWord ? suffixLength + offset : 0; - return editor + return change .deleteBackward(backward) .deleteForward(forward) .insertText(suggestionText) - .moveForward(move) + .move(move) .focus(); - }; + } // private _getFieldsSuggestions(): SuggestionGroup[] { // return [ diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx index 8f81da9a94a3..42b2f1e858d3 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx @@ -7,13 +7,14 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; import Typeahead from './typeahead'; import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv'; -import { Block, Document, Text, Value, Editor as CoreEditor } from 'slate'; -import { Editor } from '@grafana/slate-react'; +import { Block, Document, Text, Value } from 'slate'; +// @ts-ignore +import { Editor } from 'slate-react'; +// @ts-ignore import Plain from 'slate-plain-serializer'; import ReactDOM from 'react-dom'; import React from 'react'; import _ from 'lodash'; -import { CompletionItem } from 'app/types'; function flattenSuggestions(s: any) { return s ? s.reduce((acc: any, g: any) => acc.concat(g.items), []) : []; @@ -97,7 +98,7 @@ class QueryField extends React.Component { this.updateMenu(); } - onChange = ({ value }: { value: Value }) => { + onChange = ({ value }: any) => { const changed = value.document !== this.state.value.document; this.setState({ value }, () => { if (changed) { @@ -123,15 +124,14 @@ class QueryField extends React.Component { } }; - onKeyDown = (event: Event, editor: CoreEditor, next: Function) => { + onKeyDown = (event: any, change: any) => { const { typeaheadIndex, suggestions } = this.state; - const keyboardEvent = event as KeyboardEvent; - switch (keyboardEvent.key) { + switch (event.key) { case 'Escape': { if (this.menuEl) { - keyboardEvent.preventDefault(); - keyboardEvent.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); this.resetTypeahead(); return true; } @@ -139,8 +139,8 @@ class QueryField extends React.Component { } case ' ': { - if (keyboardEvent.ctrlKey) { - keyboardEvent.preventDefault(); + if (event.ctrlKey) { + event.preventDefault(); this.onTypeahead(true); return true; } @@ -151,12 +151,18 @@ class QueryField extends React.Component { case 'Enter': { if (this.menuEl) { // Dont blur input - keyboardEvent.preventDefault(); + event.preventDefault(); if (!suggestions || suggestions.length === 0) { - return next(); + return undefined; } - this.applyTypeahead(); + // Get the currently selected suggestion + const flattenedSuggestions = flattenSuggestions(suggestions); + const selected = Math.abs(typeaheadIndex); + const selectedIndex = selected % flattenedSuggestions.length || 0; + const suggestion = flattenedSuggestions[selectedIndex]; + + this.applyTypeahead(change, suggestion); return true; } break; @@ -165,7 +171,7 @@ class QueryField extends React.Component { case 'ArrowDown': { if (this.menuEl) { // Select next suggestion - keyboardEvent.preventDefault(); + event.preventDefault(); this.setState({ typeaheadIndex: typeaheadIndex + 1 }); } break; @@ -174,7 +180,7 @@ class QueryField extends React.Component { case 'ArrowUp': { if (this.menuEl) { // Select previous suggestion - keyboardEvent.preventDefault(); + event.preventDefault(); this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); } break; @@ -185,16 +191,16 @@ class QueryField extends React.Component { break; } } - return next(); + return undefined; }; - onTypeahead = (change = false, item?: any): boolean | void => { - return change; + onTypeahead = (change?: boolean, item?: any) => { + return change || this.state.value.change(); }; - applyTypeahead = (editor?: CoreEditor, suggestion?: CompletionItem): { value: Value } => { - return { value: new Value() }; - }; + applyTypeahead(change?: boolean, suggestion?: any): { value: object } { + return { value: {} }; + } resetTypeahead = () => { this.setState({ @@ -239,8 +245,15 @@ class QueryField extends React.Component { return; } + // Get the currently selected suggestion + const flattenedSuggestions = flattenSuggestions(suggestions); + const suggestion: any = _.find( + flattenedSuggestions, + suggestion => suggestion.display === item || suggestion.text === item + ); + // Manually triggering change - const change = this.applyTypeahead(); + const change = this.applyTypeahead(this.state.value.change(), suggestion); this.onChange(change); }; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx index e90aff765be7..de7f31ac7f8f 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx @@ -1,7 +1,6 @@ import React, { FunctionComponent } from 'react'; import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm'; import { useLokiSyntax } from './useLokiSyntax'; -import LokiLanguageProvider from '../language_provider'; export const LokiQueryField: FunctionComponent = ({ datasource, @@ -9,7 +8,7 @@ export const LokiQueryField: FunctionComponent = ({ ...otherProps }) => { const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax( - datasource.languageProvider as LokiLanguageProvider, + datasource.languageProvider, datasourceStatus, otherProps.absoluteRange ); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx index f343bb5eab5f..57b0b6987f06 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx @@ -2,24 +2,18 @@ import React from 'react'; // @ts-ignore import Cascader from 'rc-cascader'; - -import { SlatePrism } from '@grafana/ui'; - +// @ts-ignore +import PluginPrism from 'slate-prism'; // Components -import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField'; +import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; // Utils & Services // dom also includes Element polyfills import BracesPlugin from 'app/features/explore/slate-plugins/braces'; -import { Plugin, Node } from 'slate'; - // Types import { LokiQuery } from '../types'; -import { TypeaheadOutput } from 'app/types/explore'; +import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; import { DataSourceApi, ExploreQueryFieldProps, DataSourceStatus, DOMUtil } from '@grafana/ui'; import { AbsoluteTimeRange } from '@grafana/data'; -import { Grammar } from 'prismjs'; -import LokiLanguageProvider, { LokiHistoryItem } from '../language_provider'; -import { SuggestionsState } from 'app/features/explore/slate-plugins/suggestions'; function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DataSourceStatus) { if (datasourceStatus === DataSourceStatus.Disconnected) { @@ -34,7 +28,7 @@ function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceSta return 'Log labels'; } -function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { +function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string { // Modify suggestion based on context switch (typeaheadContext) { case 'context-labels': { @@ -69,17 +63,17 @@ export interface CascaderOption { } export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps, LokiQuery> { - history: LokiHistoryItem[]; - syntax: Grammar; + history: HistoryItem[]; + syntax: any; logLabelOptions: any[]; - syntaxLoaded: boolean; + syntaxLoaded: any; absoluteRange: AbsoluteTimeRange; onLoadOptions: (selectedOptions: CascaderOption[]) => void; onLabelsRefresh?: () => void; } export class LokiQueryFieldForm extends React.PureComponent { - plugins: Plugin[]; + plugins: any[]; modifiedSearch: string; modifiedQuery: string; @@ -88,9 +82,9 @@ export class LokiQueryFieldForm extends React.PureComponent node.object === 'block' && node.type === 'code_block', - getSyntax: (node: Node) => 'promql', + PluginPrism({ + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: (node: any) => 'promql', }), ]; } @@ -121,23 +115,27 @@ export class LokiQueryFieldForm extends React.PureComponent => { + onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { const { datasource } = this.props; - if (!datasource.languageProvider) { return { suggestions: [] }; } - const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider; const { history, absoluteRange } = this.props; - const { prefix, text, value, wrapperClasses, labelKey } = typeahead; + const { prefix, text, value, wrapperNode } = typeahead; + + // Get DOM-dependent context + const wrapperClasses = Array.from(wrapperNode.classList); + const labelKeyNode = DOMUtil.getPreviousCousin(wrapperNode, '.attr-name'); + const labelKey = labelKeyNode && labelKeyNode.textContent; + const nextChar = DOMUtil.getNextCharacter(); - const result = await lokiLanguageProvider.provideCompletionItems( + const result = datasource.languageProvider.provideCompletionItems( { text, value, prefix, wrapperClasses, labelKey }, { history, absoluteRange } ); - //console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); + console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); return result; }; @@ -153,8 +151,7 @@ export class LokiQueryFieldForm extends React.PureComponent 0; const chooserText = getChooserText(syntaxLoaded, hasLogLabels, datasourceStatus); const buttonDisabled = !syntaxLoaded || datasourceStatus === DataSourceStatus.Disconnected; diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts index 07c98cc476ce..62de5c156adc 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts +++ b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts @@ -3,7 +3,6 @@ import { DataSourceStatus } from '@grafana/ui/src/types/datasource'; import { AbsoluteTimeRange } from '@grafana/data'; import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; - import { useLokiSyntax } from './useLokiSyntax'; import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; import { makeMockLokiDatasource } from '../mocks'; diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.ts index f4ae3652e4de..7faa5a6fb247 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts +++ b/public/app/plugins/datasource/loki/components/useLokiSyntax.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +// @ts-ignore import Prism from 'prismjs'; import { DataSourceStatus } from '@grafana/ui/src/types/datasource'; import { AbsoluteTimeRange } from '@grafana/data'; diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts index 0b8caa16f0ad..4f0ac9324aa2 100644 --- a/public/app/plugins/datasource/loki/language_provider.test.ts +++ b/public/app/plugins/datasource/loki/language_provider.test.ts @@ -1,14 +1,13 @@ +// @ts-ignore import Plain from 'slate-plain-serializer'; -import { Editor as SlateEditor } from 'slate'; import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider'; import { AbsoluteTimeRange } from '@grafana/data'; import { advanceTo, clear, advanceBy } from 'jest-date-mock'; import { beforeEach } from 'test/lib/common'; - +import { DataSourceApi } from '@grafana/ui'; import { TypeaheadInput } from '../../../types'; import { makeMockLokiDatasource } from './mocks'; -import LokiDatasource from './datasource'; describe('Language completion provider', () => { const datasource = makeMockLokiDatasource({}); @@ -19,16 +18,16 @@ describe('Language completion provider', () => { }; describe('empty query suggestions', () => { - it('returns no suggestions on empty context', async () => { + it('returns no suggestions on empty context', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); - + expect(result.refresher).toBeUndefined(); expect(result.suggestions.length).toEqual(0); }); - it('returns default suggestions with history on empty context when history was provided', async () => { + it('returns default suggestions with history on empty context when history was provided', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); const history: LokiHistoryItem[] = [ @@ -37,12 +36,12 @@ describe('Language completion provider', () => { ts: 1, }, ]; - const result = await instance.provideCompletionItems( + const result = instance.provideCompletionItems( { text: '', prefix: '', value, wrapperClasses: [] }, { history, absoluteRange: rangeMock } ); expect(result.context).toBeUndefined(); - + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'History', @@ -55,7 +54,7 @@ describe('Language completion provider', () => { ]); }); - it('returns no suggestions within regexp', async () => { + it('returns no suggestions within regexp', () => { const instance = new LanguageProvider(datasource); const input = createTypeaheadInput('{} ()', '', undefined, 4, []); const history: LokiHistoryItem[] = [ @@ -64,28 +63,18 @@ describe('Language completion provider', () => { ts: 1, }, ]; - const result = await instance.provideCompletionItems(input, { history }); + const result = instance.provideCompletionItems(input, { history }); expect(result.context).toBeUndefined(); - + expect(result.refresher).toBeUndefined(); expect(result.suggestions.length).toEqual(0); }); }); describe('label suggestions', () => { - it('returns default label suggestions on label context', async () => { + it('returns default label suggestions on label context', () => { const instance = new LanguageProvider(datasource); - const value = Plain.deserialize('{}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(1).value; - const result = await instance.provideCompletionItems( - { - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }, - { absoluteRange: rangeMock } - ); + const input = createTypeaheadInput('{}', ''); + const result = instance.provideCompletionItems(input, { absoluteRange: rangeMock }); expect(result.context).toBe('context-labels'); expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]); }); @@ -94,7 +83,7 @@ describe('Language completion provider', () => { const datasource = makeMockLokiDatasource({ label1: [], label2: [] }); const provider = await getLanguageProvider(datasource); const input = createTypeaheadInput('{}', ''); - const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + const result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); expect(result.context).toBe('context-labels'); expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }, { label: 'label2' }], label: 'Labels' }]); }); @@ -103,9 +92,11 @@ describe('Language completion provider', () => { const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] }); const provider = await getLanguageProvider(datasource); const input = createTypeaheadInput('{label1=}', '=', 'label1'); - let result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock }); - - result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + let result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); + // The values for label are loaded adhoc and there is a promise returned that we have to wait for + expect(result.refresher).toBeDefined(); + await result.refresher; + result = provider.provideCompletionItems(input, { absoluteRange: rangeMock }); expect(result.context).toBe('context-label-values'); expect(result.suggestions).toEqual([ { items: [{ label: 'label1_val1' }, { label: 'label1_val2' }], label: 'Label values for "label1"' }, @@ -210,7 +201,7 @@ describe('Labels refresh', () => { }); }); -async function getLanguageProvider(datasource: LokiDatasource) { +async function getLanguageProvider(datasource: DataSourceApi) { const instance = new LanguageProvider(datasource); instance.initialRange = { from: Date.now() - 10000, @@ -233,8 +224,10 @@ function createTypeaheadInput( wrapperClasses?: string[] ): TypeaheadInput { const deserialized = Plain.deserialize(value); - const range = deserialized.selection.setAnchor(deserialized.selection.anchor.setOffset(anchorOffset || 1)); - const valueWithSelection = deserialized.setSelection(range); + const range = deserialized.selection.merge({ + anchorOffset: anchorOffset || 1, + }); + const valueWithSelection = deserialized.change().select(range).value; return { text, prefix: '', diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index ac5f877f8d43..44ba031255f0 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -6,12 +6,18 @@ import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasour import syntax from './syntax'; // Types -import { CompletionItem, LanguageProvider, TypeaheadInput, TypeaheadOutput, HistoryItem } from 'app/types/explore'; +import { + CompletionItem, + CompletionItemGroup, + LanguageProvider, + TypeaheadInput, + TypeaheadOutput, + HistoryItem, +} from 'app/types/explore'; import { LokiQuery } from './types'; import { dateTime, AbsoluteTimeRange } from '@grafana/data'; import { PromQuery } from '../prometheus/types'; - -import LokiDatasource from './datasource'; +import { DataSourceApi } from '@grafana/ui'; const DEFAULT_KEYS = ['job', 'namespace']; const EMPTY_SELECTOR = '{}'; @@ -53,9 +59,8 @@ export default class LokiLanguageProvider extends LanguageProvider { logLabelFetchTs?: number; started: boolean; initialRange: AbsoluteTimeRange; - datasource: LokiDatasource; - constructor(datasource: LokiDatasource, initialValues?: any) { + constructor(datasource: DataSourceApi, initialValues?: any) { super(); this.datasource = datasource; @@ -64,7 +69,6 @@ export default class LokiLanguageProvider extends LanguageProvider { Object.assign(this, initialValues); } - // Strip syntax chars cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); @@ -107,14 +111,14 @@ export default class LokiLanguageProvider extends LanguageProvider { * @param context.absoluteRange Required in case we are doing getLabelCompletionItems * @param context.history Optional used only in getEmptyCompletionItems */ - async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise { + provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): TypeaheadOutput { const { wrapperClasses, value } = input; // Local text properties const empty = value.document.text.length === 0; // Determine candidates by CSS context if (_.includes(wrapperClasses, 'context-labels')) { // Suggestions for {|} and {foo=|} - return await this.getLabelCompletionItems(input, context); + return this.getLabelCompletionItems(input, context); } else if (empty) { return this.getEmptyCompletionItems(context || {}); } @@ -126,7 +130,7 @@ export default class LokiLanguageProvider extends LanguageProvider { getEmptyCompletionItems(context: any): TypeaheadOutput { const { history } = context; - const suggestions = []; + const suggestions: CompletionItemGroup[] = []; if (history && history.length > 0) { const historyItems = _.chain(history) @@ -149,14 +153,15 @@ export default class LokiLanguageProvider extends LanguageProvider { return { suggestions }; } - async getLabelCompletionItems( + getLabelCompletionItems( { text, wrapperClasses, labelKey, value }: TypeaheadInput, { absoluteRange }: any - ): Promise { + ): TypeaheadOutput { let context: string; - const suggestions = []; + let refresher: Promise = null; + const suggestions: CompletionItemGroup[] = []; const line = value.anchorBlock.getText(); - const cursorOffset: number = value.selection.anchor.offset; + const cursorOffset: number = value.anchorOffset; // Use EMPTY_SELECTOR until series API is implemented for facetting const selector = EMPTY_SELECTOR; @@ -166,20 +171,19 @@ export default class LokiLanguageProvider extends LanguageProvider { } catch {} const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - if ((text && text.match(/^!?=~?/)) || wrapperClasses.includes('attr-value')) { + if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { // Label values if (labelKey && this.labelValues[selector]) { - let labelValues = this.labelValues[selector][labelKey]; - if (!labelValues) { - await this.fetchLabelValues(labelKey, absoluteRange); - labelValues = this.labelValues[selector][labelKey]; + const labelValues = this.labelValues[selector][labelKey]; + if (labelValues) { + context = 'context-label-values'; + suggestions.push({ + label: `Label values for "${labelKey}"`, + items: labelValues.map(wrapLabel), + }); + } else { + refresher = this.fetchLabelValues(labelKey, absoluteRange); } - - context = 'context-label-values'; - suggestions.push({ - label: `Label values for "${labelKey}"`, - items: labelValues.map(wrapLabel), - }); } } else { // Label keys @@ -193,7 +197,7 @@ export default class LokiLanguageProvider extends LanguageProvider { } } - return { context, suggestions }; + return { context, refresher, suggestions }; } async importQueries(queries: LokiQuery[], datasourceType: string): Promise { diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts index 7e91c51c1056..49c2de7dcc01 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -1,6 +1,6 @@ -import LokiDatasource from './datasource'; +import { DataSourceApi } from '@grafana/ui'; -export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): LokiDatasource { +export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): DataSourceApi { const labels = Object.keys(labelsAndValues); return { metadataRequest: (url: string) => { diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts index 2e83723a8156..0748b4e5ffd2 100644 --- a/public/app/plugins/datasource/loki/syntax.ts +++ b/public/app/plugins/datasource/loki/syntax.ts @@ -1,8 +1,6 @@ -import { Grammar } from 'prismjs'; - /* tslint:disable max-line-length */ -const tokenizer: Grammar = { +const tokenizer = { comment: { pattern: /(^|[^\n])#.*/, lookbehind: true, diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index 963a2d79a302..99eef38bde3a 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -2,22 +2,20 @@ import _ from 'lodash'; import React from 'react'; // @ts-ignore import Cascader from 'rc-cascader'; - -import { SlatePrism } from '@grafana/ui'; - +// @ts-ignore +import PluginPrism from 'slate-prism'; +// @ts-ignore import Prism from 'prismjs'; import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; // dom also includes Element polyfills import BracesPlugin from 'app/features/explore/slate-plugins/braces'; -import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField'; +import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; import { PromQuery, PromContext, PromOptions } from '../types'; import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; import { ExploreQueryFieldProps, DataSourceStatus, QueryHint, DOMUtil } from '@grafana/ui'; import { isDataFrame, toLegacyResponseData } from '@grafana/data'; import { PrometheusDatasource } from '../datasource'; -import PromQlLanguageProvider from '../language_provider'; -import { SuggestionsState } from 'app/features/explore/slate-plugins/suggestions'; const HISTOGRAM_GROUP = '__histograms__'; const METRIC_MARK = 'metric'; @@ -69,7 +67,7 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad return [...options, ...metricsOptions]; } -export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { +export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string { // Modify suggestion based on context switch (typeaheadContext) { case 'context-labels': { @@ -104,7 +102,7 @@ interface CascaderOption { } interface PromQueryFieldProps extends ExploreQueryFieldProps { - history: Array>; + history: HistoryItem[]; } interface PromQueryFieldState { @@ -115,7 +113,7 @@ interface PromQueryFieldState { class PromQueryField extends React.PureComponent { plugins: any[]; - languageProvider: PromQlLanguageProvider; + languageProvider: any; languageProviderInitializationPromise: CancelablePromise; constructor(props: PromQueryFieldProps, context: React.Context) { @@ -127,7 +125,7 @@ class PromQueryField extends React.PureComponent node.type === 'code_block', getSyntax: (node: any) => 'promql', }), @@ -254,7 +252,7 @@ class PromQueryField extends React.PureComponent => { + onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { if (!this.languageProvider) { return { suggestions: [] }; } const { history } = this.props; - const { prefix, text, value, wrapperClasses, labelKey } = typeahead; + const { prefix, text, value, wrapperNode } = typeahead; + + // Get DOM-dependent context + const wrapperClasses = Array.from(wrapperNode.classList); + const labelKeyNode = DOMUtil.getPreviousCousin(wrapperNode, '.attr-name'); + const labelKey = labelKeyNode && labelKeyNode.textContent; + const nextChar = DOMUtil.getNextCharacter(); - const result = await this.languageProvider.provideCompletionItems( + const result = this.languageProvider.provideCompletionItems( { text, value, prefix, wrapperClasses, labelKey }, { history } ); - // console.log('handleTypeahead', wrapperClasses, text, prefix, labelKey, result.context); + console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); return result; }; diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 018b32e49814..86cdb57f02a6 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -1,28 +1,23 @@ import _ from 'lodash'; -import { dateTime } from '@grafana/data'; - import { CompletionItem, CompletionItemGroup, LanguageProvider, TypeaheadInput, TypeaheadOutput, - HistoryItem, } from 'app/types/explore'; import { parseSelector, processLabels, processHistogramLabels } from './language_utils'; import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql'; - -import { PrometheusDatasource } from './datasource'; -import { PromQuery } from './types'; +import { dateTime } from '@grafana/data'; const DEFAULT_KEYS = ['job', 'instance']; const EMPTY_SELECTOR = '{}'; const HISTORY_ITEM_COUNT = 5; const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h -const wrapLabel = (label: string): CompletionItem => ({ label }); +const wrapLabel = (label: string) => ({ label }); const setFunctionKind = (suggestion: CompletionItem): CompletionItem => { suggestion.kind = 'function'; @@ -35,12 +30,10 @@ export function addHistoryMetadata(item: CompletionItem, history: any[]): Comple const count = historyForItem.length; const recent = historyForItem[0]; let hint = `Queried ${count} times in the last 24h.`; - if (recent) { const lastQueried = dateTime(recent.ts).fromNow(); hint = `${hint} Last queried ${lastQueried}.`; } - return { ...item, documentation: hint, @@ -54,9 +47,8 @@ export default class PromQlLanguageProvider extends LanguageProvider { labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] metrics?: string[]; startTask: Promise; - datasource: PrometheusDatasource; - constructor(datasource: PrometheusDatasource, initialValues?: any) { + constructor(datasource: any, initialValues?: any) { super(); this.datasource = datasource; @@ -68,11 +60,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { Object.assign(this, initialValues); } - // Strip syntax chars cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); - get syntax() { + getSyntax() { return PromqlSyntax; } @@ -115,46 +106,39 @@ export default class PromQlLanguageProvider extends LanguageProvider { } }; - provideCompletionItems = async ( - { prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput, - context: { history: Array> } = { history: [] } - ): Promise => { + // Keep this DOM-free for testing + provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput { // Local text properties const empty = value.document.text.length === 0; - const selectedLines = value.document.getTextsAtRange(value.selection); - const currentLine = selectedLines.size === 1 ? selectedLines.first().getText() : null; - - const nextCharacter = currentLine ? currentLine[value.selection.anchor.offset] : null; + const selectedLines = value.document.getTextsAtRangeAsArray(value.selection); + const currentLine = selectedLines.length === 1 ? selectedLines[0] : null; + const nextCharacter = currentLine ? currentLine.text[value.selection.anchorOffset] : null; // Syntax spans have 3 classes by default. More indicate a recognized token const tokenRecognized = wrapperClasses.length > 3; // Non-empty prefix, but not inside known token const prefixUnrecognized = prefix && !tokenRecognized; - // Prevent suggestions in `function(|suffix)` const noSuffix = !nextCharacter || nextCharacter === ')'; - - // Empty prefix is safe if it does not immediately follow a complete expression and has no text after it + // Empty prefix is safe if it does not immediately folllow a complete expression and has no text after it const safeEmptyPrefix = prefix === '' && !text.match(/^[\]})\s]+$/) && noSuffix; - // About to type next operand if preceded by binary operator - const operatorsPattern = /[+\-*/^%]/; - const isNextOperand = text.match(operatorsPattern); + const isNextOperand = text.match(/[+\-*/^%]/); // Determine candidates by CSS context - if (wrapperClasses.includes('context-range')) { + if (_.includes(wrapperClasses, 'context-range')) { // Suggestions for metric[|] return this.getRangeCompletionItems(); - } else if (wrapperClasses.includes('context-labels')) { + } else if (_.includes(wrapperClasses, 'context-labels')) { // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} - return this.getLabelCompletionItems({ prefix, text, value, labelKey, wrapperClasses }); - } else if (wrapperClasses.includes('context-aggregation')) { + return this.getLabelCompletionItems.apply(this, arguments); + } else if (_.includes(wrapperClasses, 'context-aggregation')) { // Suggestions for sum(metric) by (|) - return this.getAggregationCompletionItems({ prefix, text, value, labelKey, wrapperClasses }); + return this.getAggregationCompletionItems.apply(this, arguments); } else if (empty) { // Suggestions for empty query field - return this.getEmptyCompletionItems(context); - } else if ((prefixUnrecognized && noSuffix) || safeEmptyPrefix || isNextOperand) { + return this.getEmptyCompletionItems(context || {}); + } else if (prefixUnrecognized || safeEmptyPrefix || isNextOperand) { // Show term suggestions in a couple of scenarios return this.getTermCompletionItems(); } @@ -162,20 +146,20 @@ export default class PromQlLanguageProvider extends LanguageProvider { return { suggestions: [], }; - }; + } - getEmptyCompletionItems = (context: { history: Array> }): TypeaheadOutput => { + getEmptyCompletionItems(context: any): TypeaheadOutput { const { history } = context; - const suggestions = []; + let suggestions: CompletionItemGroup[] = []; - if (history && history.length) { + if (history && history.length > 0) { const historyItems = _.chain(history) - .map(h => h.query.expr) + .map((h: any) => h.query.expr) .filter() .uniq() .take(HISTORY_ITEM_COUNT) .map(wrapLabel) - .map(item => addHistoryMetadata(item, history)) + .map((item: CompletionItem) => addHistoryMetadata(item, history)) .value(); suggestions.push({ @@ -187,14 +171,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { } const termCompletionItems = this.getTermCompletionItems(); - suggestions.push(...termCompletionItems.suggestions); + suggestions = [...suggestions, ...termCompletionItems.suggestions]; return { suggestions }; - }; + } - getTermCompletionItems = (): TypeaheadOutput => { + getTermCompletionItems(): TypeaheadOutput { const { metrics } = this; - const suggestions = []; + const suggestions: CompletionItemGroup[] = []; suggestions.push({ prefixMatch: true, @@ -202,15 +186,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { items: FUNCTIONS.map(setFunctionKind), }); - if (metrics && metrics.length) { + if (metrics && metrics.length > 0) { suggestions.push({ label: 'Metrics', items: metrics.map(wrapLabel), }); } - return { suggestions }; - }; + } getRangeCompletionItems(): TypeaheadOutput { return { @@ -236,21 +219,21 @@ export default class PromQlLanguageProvider extends LanguageProvider { ); } - getAggregationCompletionItems = ({ value }: TypeaheadInput): TypeaheadOutput => { + getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput { const refresher: Promise = null; const suggestions: CompletionItemGroup[] = []; // Stitch all query lines together to support multi-line queries let queryOffset; - const queryText = value.document.getBlocks().reduce((text: string, block) => { + const queryText = value.document.getBlocks().reduce((text: string, block: any) => { const blockText = block.getText(); if (value.anchorBlock.key === block.key) { // Newline characters are not accounted for but this is irrelevant // for the purpose of extracting the selector string - queryOffset = value.selection.anchor.offset + text.length; + queryOffset = value.anchorOffset + text.length; } - - return text + blockText; + text += blockText; + return text; }, ''); // Try search for selector part on the left-hand side, such as `sum (m) by (l)` @@ -276,10 +259,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { return result; } + let selectorString = queryText.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); + // Range vector syntax not accounted for by subsequent parse so discard it if present - const selectorString = queryText - .slice(openParensSelectorIndex + 1, closeParensSelectorIndex) - .replace(/\[[^\]]+\]$/, ''); + selectorString = selectorString.replace(/\[[^\]]+\]$/, ''); const selector = parseSelector(selectorString, selectorString.length - 2).selector; @@ -291,16 +274,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { } return result; - }; + } - getLabelCompletionItems = async ({ - text, - wrapperClasses, - labelKey, - value, - }: TypeaheadInput): Promise => { + getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput { + let context: string; + let refresher: Promise = null; + const suggestions: CompletionItemGroup[] = []; const line = value.anchorBlock.getText(); - const cursorOffset = value.selection.anchor.offset; + const cursorOffset: number = value.anchorOffset; // Get normalized selector let selector; @@ -311,23 +292,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { } catch { selector = EMPTY_SELECTOR; } - - const containsMetric = selector.includes('__name__='); + const containsMetric = selector.indexOf('__name__=') > -1; const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - // Query labels for selector - if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) { - if (selector === EMPTY_SELECTOR) { - // Query label values for default labels - await Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); - } else { - await this.fetchSeriesLabels(selector, !containsMetric); - } - } - - const suggestions = []; - let context: string; - if ((text && text.match(/^!?=~?/)) || wrapperClasses.includes('attr-value')) { + if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { // Label values if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) { const labelValues = this.labelValues[selector][labelKey]; @@ -340,20 +308,27 @@ export default class PromQlLanguageProvider extends LanguageProvider { } else { // Label keys const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); - if (labelKeys) { const possibleKeys = _.difference(labelKeys, existingKeys); - if (possibleKeys.length) { + if (possibleKeys.length > 0) { context = 'context-labels'; - const newItems = possibleKeys.map(key => ({ label: key })); - const newSuggestion: CompletionItemGroup = { label: `Labels`, items: newItems }; - suggestions.push(newSuggestion); + suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); } } } - return { context, suggestions }; - }; + // Query labels for selector + if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) { + if (selector === EMPTY_SELECTOR) { + // Query label values for default labels + refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); + } else { + refresher = this.fetchSeriesLabels(selector, !containsMetric); + } + } + + return { context, refresher, suggestions }; + } fetchLabelValues = async (key: string) => { try { diff --git a/public/app/plugins/datasource/prometheus/language_utils.ts b/public/app/plugins/datasource/prometheus/language_utils.ts index 535f86fc8bcd..f10d9eed4224 100644 --- a/public/app/plugins/datasource/prometheus/language_utils.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.ts @@ -16,13 +16,13 @@ export const processHistogramLabels = (labels: string[]) => { return { values: { __name__: result } }; }; -export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) { +export function processLabels(labels: any, withName = false) { const values: { [key: string]: string[] } = {}; - labels.forEach(l => { + labels.forEach((l: any) => { const { __name__, ...rest } = l; if (withName) { values['__name__'] = values['__name__'] || []; - if (!values['__name__'].includes(__name__)) { + if (values['__name__'].indexOf(__name__) === -1) { values['__name__'].push(__name__); } } @@ -31,7 +31,7 @@ export function processLabels(labels: Array<{ [key: string]: string }>, withName if (!values[key]) { values[key] = []; } - if (!values[key].includes(rest[key])) { + if (values[key].indexOf(rest[key]) === -1) { values[key].push(rest[key]); } }); diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts index 9171201fc377..fe1679eb94b5 100644 --- a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts @@ -1,22 +1,21 @@ +// @ts-ignore import Plain from 'slate-plain-serializer'; -import { Editor as SlateEditor } from 'slate'; + import LanguageProvider from '../language_provider'; -import { PrometheusDatasource } from '../datasource'; -import { HistoryItem } from 'app/types'; -import { PromQuery } from '../types'; describe('Language completion provider', () => { - const datasource: PrometheusDatasource = ({ + const datasource = { metadataRequest: () => ({ data: { data: [] as any[] } }), getTimeRange: () => ({ start: 0, end: 1 }), - } as any) as PrometheusDatasource; + }; describe('empty query suggestions', () => { - it('returns default suggestions on empty context', async () => { + it('returns default suggestions on emtpty context', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -24,11 +23,12 @@ describe('Language completion provider', () => { ]); }); - it('returns default suggestions with metrics on empty context when metrics were provided', async () => { + it('returns default suggestions with metrics on emtpty context when metrics were provided', () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const value = Plain.deserialize(''); - const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -39,21 +39,17 @@ describe('Language completion provider', () => { ]); }); - it('returns default suggestions with history on empty context when history was provided', async () => { + it('returns default suggestions with history on emtpty context when history was provided', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize(''); - const history: Array> = [ + const history = [ { - ts: 0, query: { refId: '1', expr: 'metric' }, }, ]; - const result = await instance.provideCompletionItems( - { text: '', prefix: '', value, wrapperClasses: [] }, - { history } - ); + const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history }); expect(result.context).toBeUndefined(); - + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'History', @@ -71,16 +67,17 @@ describe('Language completion provider', () => { }); describe('range suggestions', () => { - it('returns range suggestions in range context', async () => { + it('returns range suggestions in range context', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize('1'); - const result = await instance.provideCompletionItems({ + const result = instance.provideCompletionItems({ text: '1', prefix: '1', value, wrapperClasses: ['context-range'], }); expect(result.context).toBe('context-range'); + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { items: [ @@ -99,12 +96,12 @@ describe('Language completion provider', () => { }); describe('metric suggestions', () => { - it('returns metrics and function suggestions in an unknown context', async () => { + it('returns metrics and function suggestions in an unknown context', () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); - let value = Plain.deserialize('a'); - value = value.setSelection({ anchor: { offset: 1 }, focus: { offset: 1 } }); - const result = await instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] }); + const value = Plain.deserialize('a'); + const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -115,11 +112,12 @@ describe('Language completion provider', () => { ]); }); - it('returns metrics and function suggestions after a binary operator', async () => { + it('returns metrics and function suggestions after a binary operator', () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const value = Plain.deserialize('*'); - const result = await instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] }); + const result = instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] }); expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); expect(result.suggestions).toMatchObject([ { label: 'Functions', @@ -130,30 +128,34 @@ describe('Language completion provider', () => { ]); }); - it('returns no suggestions at the beginning of a non-empty function', async () => { + it('returns no suggestions at the beginning of a non-empty function', () => { const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const value = Plain.deserialize('sum(up)'); - const ed = new SlateEditor({ value }); - - const valueWithSelection = ed.moveForward(4).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 4, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', value: valueWithSelection, wrapperClasses: [], }); expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); expect(result.suggestions.length).toEqual(0); }); }); describe('label suggestions', () => { - it('returns default label suggestions on label context and no metric', async () => { + it('returns default label suggestions on label context and no metric', () => { const instance = new LanguageProvider(datasource); const value = Plain.deserialize('{}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(1).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 1, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], @@ -163,16 +165,14 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); }); - it('returns label suggestions on label context and metric', async () => { - const datasources: PrometheusDatasource = ({ - metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }), - getTimeRange: () => ({ start: 0, end: 1 }), - } as any) as PrometheusDatasource; - const instance = new LanguageProvider(datasources, { labelKeys: { '{__name__="metric"}': ['bar'] } }); + it('returns label suggestions on label context and metric', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); const value = Plain.deserialize('metric{}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(7).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 7, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], @@ -182,32 +182,16 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label suggestions on label context but leaves out labels that already exist', async () => { - const datasources: PrometheusDatasource = ({ - metadataRequest: () => ({ - data: { - data: [ - { - __name__: 'metric', - bar: 'asdasd', - job1: 'dsadsads', - job2: 'fsfsdfds', - job3: 'dsadsad', - }, - ], - }, - }), - getTimeRange: () => ({ start: 0, end: 1 }), - } as any) as PrometheusDatasource; - const instance = new LanguageProvider(datasources, { - labelKeys: { - '{job1="foo",job2!="foo",job3=~"foo",__name__="metric"}': ['bar', 'job1', 'job2', 'job3', '__name__'], - }, + it('returns label suggestions on label context but leaves out labels that already exist', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }, }); - const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(54).value; - const result = await instance.provideCompletionItems({ + const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); + const range = value.selection.merge({ + anchorOffset: 36, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], @@ -217,15 +201,15 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label value suggestions inside a label value context after a negated matching operator', async () => { + it('returns label value suggestions inside a label value context after a negated matching operator', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{}': ['label'] }, labelValues: { '{}': { label: ['a', 'b', 'c'] } }, }); const value = Plain.deserialize('{label!=}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(8).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ anchorOffset: 8 }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '!=', prefix: '', wrapperClasses: ['context-labels'], @@ -241,30 +225,35 @@ describe('Language completion provider', () => { ]); }); - it('returns a refresher on label context and unavailable metric', async () => { + it('returns a refresher on label context and unavailable metric', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } }); const value = Plain.deserialize('metric{}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(7).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 7, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-labels'], value: valueWithSelection, }); expect(result.context).toBeUndefined(); + expect(result.refresher).toBeInstanceOf(Promise); expect(result.suggestions).toEqual([]); }); - it('returns label values on label context when given a metric and a label key', async () => { + it('returns label values on label context when given a metric and a label key', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] }, labelValues: { '{__name__="metric"}': { bar: ['baz'] } }, }); const value = Plain.deserialize('metric{bar=ba}'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(13).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 13, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '=ba', prefix: 'ba', wrapperClasses: ['context-labels'], @@ -275,12 +264,14 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); }); - it('returns label suggestions on aggregation context and metric w/ selector', async () => { + it('returns label suggestions on aggregation context and metric w/ selector', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } }); const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(26).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 26, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -290,12 +281,14 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label suggestions on aggregation context and metric w/o selector', async () => { + it('returns label suggestions on aggregation context and metric w/o selector', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); const value = Plain.deserialize('sum(metric) by ()'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(16).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 16, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -305,16 +298,15 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); }); - it('returns label suggestions inside a multi-line aggregation context', async () => { + it('returns label suggestions inside a multi-line aggregation context', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum(\nmetric\n)\nby ()'); - const aggregationTextBlock = value.document.getBlocks().get(3); - const ed = new SlateEditor({ value }); - ed.moveToStartOfNode(aggregationTextBlock); - const valueWithSelection = ed.moveForward(4).value; - const result = await instance.provideCompletionItems({ + const aggregationTextBlock = value.document.getBlocksAsArray()[3]; + const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -329,14 +321,16 @@ describe('Language completion provider', () => { ]); }); - it('returns label suggestions inside an aggregation context with a range vector', async () => { + it('returns label suggestions inside an aggregation context with a range vector', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum(rate(metric[1h])) by ()'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(26).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 26, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -351,14 +345,16 @@ describe('Language completion provider', () => { ]); }); - it('returns label suggestions inside an aggregation context with a range vector and label', async () => { + it('returns label suggestions inside an aggregation context with a range vector and label', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(42).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 42, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -373,14 +369,16 @@ describe('Language completion provider', () => { ]); }); - it('returns no suggestions inside an unclear aggregation context using alternate syntax', async () => { + it('returns no suggestions inside an unclear aggregation context using alternate syntax', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum by ()'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(8).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 8, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], @@ -390,14 +388,16 @@ describe('Language completion provider', () => { expect(result.suggestions).toEqual([]); }); - it('returns label suggestions inside an aggregation context using alternate syntax', async () => { + it('returns label suggestions inside an aggregation context using alternate syntax', () => { const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, }); const value = Plain.deserialize('sum by () (metric)'); - const ed = new SlateEditor({ value }); - const valueWithSelection = ed.moveForward(8).value; - const result = await instance.provideCompletionItems({ + const range = value.selection.merge({ + anchorOffset: 8, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: ['context-aggregation'], diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 3b06385efe8b..09114c247384 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -23,18 +23,11 @@ import { import { Emitter } from 'app/core/core'; import TableModel from 'app/core/table_model'; -import { Value } from 'slate'; - -import { Editor } from '@grafana/slate-react'; export enum ExploreMode { Metrics = 'Metrics', Logs = 'Logs', } -export enum CompletionItemKind { - GroupTitle = 'GroupTitle', -} - export interface CompletionItem { /** * The label of this completion item. By default @@ -42,48 +35,40 @@ export interface CompletionItem { * this completion. */ label: string; - /** - * The kind of this completion item. An icon is chosen - * by the editor based on the kind. + * The kind of this completion item. Based on the kind + * an icon is chosen by the editor. */ - kind?: CompletionItemKind | string; - + kind?: string; /** * A human-readable string with additional information * about this item, like type or symbol information. */ detail?: string; - /** * A human-readable string, can be Markdown, that represents a doc-comment. */ documentation?: string; - /** * A string that should be used when comparing this item * with other items. When `falsy` the `label` is used. */ sortText?: string; - /** * A string that should be used when filtering a set of * completion items. When `falsy` the `label` is used. */ filterText?: string; - /** * A string or snippet that should be inserted in a document when selecting * this completion. When `falsy` the `label` is used. */ insertText?: string; - /** * Delete number of characters before the caret position, * by default the letters from the beginning of the word. */ deleteBackwards?: number; - /** * Number of steps to move after the insertion, can be negative. */ @@ -95,22 +80,18 @@ export interface CompletionItemGroup { * Label that will be displayed for all entries of this group. */ label: string; - /** * List of suggestions of this group. */ items: CompletionItem[]; - /** * If true, match only by prefix (and not mid-word). */ prefixMatch?: boolean; - /** * If true, do not filter items in this group based on the search. */ skipFilter?: boolean; - /** * If true, do not sort items. */ @@ -313,7 +294,7 @@ export interface HistoryItem { } export abstract class LanguageProvider { - datasource: DataSourceApi; + datasource: any; request: (url: string, params?: any) => Promise; /** * Returns startTask that resolves with a task list when main syntax is loaded. @@ -328,12 +309,13 @@ export interface TypeaheadInput { prefix: string; wrapperClasses: string[]; labelKey?: string; - value?: Value; - editor?: Editor; + //Should be Value from slate + value?: any; } export interface TypeaheadOutput { context?: string; + refresher?: Promise<{}>; suggestions: CompletionItemGroup[]; } diff --git a/public/sass/components/_slate_editor.scss b/public/sass/components/_slate_editor.scss index fe0d9c10f050..50a58f8ff0cb 100644 --- a/public/sass/components/_slate_editor.scss +++ b/public/sass/components/_slate_editor.scss @@ -30,9 +30,9 @@ .typeahead { position: absolute; z-index: auto; - top: 100px; - left: 160px; - //opacity: 0; + top: -10000px; + left: -10000px; + opacity: 0; border-radius: $border-radius; border: $panel-border; max-height: calc(66vh); @@ -43,7 +43,7 @@ list-style: none; background: $panel-bg; color: $text-color; - //transition: opacity 0.4s ease-out; + transition: opacity 0.4s ease-out; box-shadow: $typeahead-shadow; } diff --git a/tsconfig.json b/tsconfig.json index da5818b01de2..cffbb3319694 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,8 +31,7 @@ "typeRoots": ["node_modules/@types", "public/app/types"], "paths": { "app": ["app"], - "sass": ["sass"], - "@grafana/slate-react": ["../node_modules/@types/slate-react"] + "sass": ["sass"] }, "skipLibCheck": true, "preserveSymlinks": true diff --git a/yarn.lock b/yarn.lock index 1f6b4fe03350..3493e5c28db6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,28 +1227,6 @@ unique-filename "^1.1.1" which "^1.3.1" -"@grafana/slate-react@0.22.9-grafana": - version "0.22.9-grafana" - resolved "https://registry.yarnpkg.com/@grafana/slate-react/-/slate-react-0.22.9-grafana.tgz#07f35f0ffc018f616b9f82fa6e5ba65fae75c6a0" - integrity sha512-9NYjwabVOUQ/e4Y/Wm+sgePM65rb/gju59D52t4O42HsIm9exXv+SLajEBF/HiLHzuH5V+5uuHajbzv0vuE2VA== - dependencies: - debug "^3.1.0" - get-window "^1.1.1" - is-window "^1.0.2" - lodash "^4.1.1" - memoize-one "^4.0.0" - prop-types "^15.5.8" - react-immutable-proptypes "^2.1.0" - selection-is-backward "^1.0.0" - slate-base64-serializer "^0.2.111" - slate-dev-environment "^0.2.2" - slate-hotkeys "^0.2.9" - slate-plain-serializer "^0.7.10" - slate-prop-types "^0.5.41" - slate-react-placeholder "^0.2.8" - tiny-invariant "^1.0.1" - tiny-warning "^0.0.3" - "@icons/material@^0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" @@ -3430,26 +3408,10 @@ version "7.0.11" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88" -"@types/slate-plain-serializer@0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@types/slate-plain-serializer/-/slate-plain-serializer-0.6.1.tgz#c392ce51621f7c55df0976f161dcfca18bd559ee" - integrity sha512-5meyKFvmWH1T02j2dbAaY8kn/FNofxP79jV3TsfuLsUIeHkON5CroBxAyrgkYF4vHp+MVWZddI36Yvwl7Y0Feg== - dependencies: - "@types/slate" "*" - -"@types/slate-react@0.22.5": - version "0.22.5" - resolved "https://registry.yarnpkg.com/@types/slate-react/-/slate-react-0.22.5.tgz#a10796758aa6b3133e1c777959facbf8806959f7" - integrity sha512-WKJic5LlNRNUCnD6lEdlOZCcXWoDN8Ais2CmwVMn8pdt5Kh8hJsTYhXawNxOShPIOLVB+G+aVZNAXAAubEOpaw== - dependencies: - "@types/react" "*" - "@types/slate" "*" - immutable "^3.8.2" - -"@types/slate@*", "@types/slate@0.47.1": - version "0.47.1" - resolved "https://registry.yarnpkg.com/@types/slate/-/slate-0.47.1.tgz#6c66f82df085c764039eea2229be763f7e1906fd" - integrity sha512-2ZlnWI6/RYMXxeGFIeZtvmaXAeYAJh4ZVumziqVl77/liNEi9hOwkUTU2zFu+j/z21v385I2WVPl8sgadxfzXg== +"@types/slate@0.44.11": + version "0.44.11" + resolved "https://registry.yarnpkg.com/@types/slate/-/slate-0.44.11.tgz#152568096d1a089fa4c5bb03de1cf044a377206c" + integrity sha512-UnOGipgkE1+rq3L4JjsTO0b02FbT6b59+0/hkW/QFBDvCcxCSAdwdr9HYjXkMSCSVlcsEfdC/cz+XOaB+tGvlg== dependencies: "@types/react" "*" immutable "^3.8.2" @@ -4713,6 +4675,7 @@ bail@^1.0.0: balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= baron@3.0.3: version "3.0.3" @@ -4871,6 +4834,7 @@ boxen@^2.1.0: brace-expansion@^1.0.0, brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" @@ -5759,6 +5723,7 @@ compression@^1.5.2: concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= concat-stream@1.6.2, concat-stream@^1.4.6, concat-stream@^1.5.0: version "1.6.2" @@ -7189,7 +7154,6 @@ dir-glob@^2.0.0: direction@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/direction/-/direction-0.1.5.tgz#ce5d797f97e26f8be7beff53f7dc40e1c1a9ec4c" - integrity sha1-zl15f5fib4vnvv9T99xA4cGp7Ew= discontinuous-range@1.0.0: version "1.0.0" @@ -7781,7 +7745,6 @@ esrecurse@^4.1.0: esrever@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/esrever/-/esrever-0.2.0.tgz#96e9d28f4f1b1a76784cd5d490eaae010e7407b8" - integrity sha1-lunSj08bGnZ4TNXUkOquAQ50B7g= estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.2.0" @@ -8325,6 +8288,7 @@ for-in@^0.1.3: for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= for-own@^0.1.3, for-own@^0.1.4: version "0.1.5" @@ -8485,6 +8449,7 @@ fs-write-stream-atomic@^1.0.8, fs-write-stream-atomic@~1.0.10: fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: version "1.2.9" @@ -8937,8 +8902,9 @@ got@^6.7.1: url-parse-lax "^1.0.0" graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: - version "4.1.15" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" + version "4.2.2" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02" + integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q== "graceful-readlink@>= 1.0.0": version "1.0.1" @@ -9668,6 +9634,7 @@ infer-owner@^1.0.4: inflight@^1.0.4, inflight@~1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= dependencies: once "^1.3.0" wrappy "1" @@ -9976,6 +9943,10 @@ is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" +is-empty@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-empty/-/is-empty-1.2.0.tgz#de9bb5b278738a05a0b09a57e1fb4d4a341a9f6b" + is-equal-shallow@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" @@ -9989,6 +9960,7 @@ is-extendable@^0.1.0, is-extendable@^0.1.1: is-extendable@^1.0.0, is-extendable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== dependencies: is-plain-object "^2.0.4" @@ -10050,7 +10022,7 @@ is-hexadecimal@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835" -is-hotkey@0.1.4: +is-hotkey@0.1.4, is-hotkey@^0.1.1: version "0.1.4" resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.4.tgz#c34d2c85d6ec8d09a871dcf71931c8067a824c7d" @@ -10305,6 +10277,7 @@ isobject@^2.0.0: isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= isobject@^4.0.0: version "4.0.0" @@ -10941,7 +10914,7 @@ kew@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/kew/-/kew-0.7.0.tgz#79d93d2d33363d6fdd2970b335d9141ad591d79b" -keycode@^2.2.0: +keycode@^2.1.2, keycode@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04" @@ -11364,8 +11337,9 @@ lockfile@^1.0.4: signal-exit "^3.0.2" lodash-es@^4.17.11, lodash-es@^4.2.1: - version "4.17.11" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" + integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== lodash._baseuniq@~4.6.0: version "4.6.0" @@ -11382,7 +11356,7 @@ lodash._getnative@^3.0.0: version "3.9.1" resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" -lodash._reinterpolate@^3.0.0, lodash._reinterpolate@~3.0.0: +lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= @@ -11453,8 +11427,9 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" lodash.mergewith@^4.6.1: - version "4.6.1" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== lodash.once@^4.1.1: version "4.1.1" @@ -11477,7 +11452,7 @@ lodash.tail@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" -lodash.template@^4.0.2: +lodash.template@^4.0.2, lodash.template@^4.2.4: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== @@ -11485,20 +11460,12 @@ lodash.template@^4.0.2: lodash._reinterpolate "^3.0.0" lodash.templatesettings "^4.0.0" -lodash.template@^4.2.4: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" - integrity sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A= - dependencies: - lodash._reinterpolate "~3.0.0" - lodash.templatesettings "^4.0.0" - lodash.templatesettings@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316" - integrity sha1-K01OlbpEDZFf8IvImeRVNmZxMxY= + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33" + integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ== dependencies: - lodash._reinterpolate "~3.0.0" + lodash._reinterpolate "^3.0.0" lodash.throttle@^4.1.1: version "4.1.1" @@ -12001,6 +11968,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: "minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.0, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" @@ -12021,6 +11989,7 @@ minimist-options@^3.0.1: minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= minimist@1.1.x: version "1.1.3" @@ -12063,8 +12032,9 @@ mississippi@^3.0.0: through2 "^2.0.0" mixin-deep@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== dependencies: for-in "^1.0.2" is-extendable "^1.0.1" @@ -12965,6 +12935,7 @@ on-headers@~1.0.2: once@^1.3.0, once@^1.3.1, once@^1.4.0, once@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= dependencies: wrappy "1" @@ -13387,6 +13358,7 @@ path-exists@^3.0.0: path-is-absolute@^1.0.0, path-is-absolute@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= path-is-inside@^1.0.1, path-is-inside@^1.0.2, path-is-inside@~1.0.2: version "1.0.2" @@ -14375,7 +14347,7 @@ pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" -prismjs@1.16.0, prismjs@^1.8.4, prismjs@~1.16.0: +prismjs@1.16.0, prismjs@^1.13.0, prismjs@^1.8.4, prismjs@~1.16.0: version "1.16.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.16.0.tgz#406eb2c8aacb0f5f0f1167930cb83835d10a4308" optionalDependencies: @@ -15100,6 +15072,12 @@ react-popper@^1.3.3: typed-styles "^0.0.7" warning "^4.0.2" +react-portal@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.2.0.tgz#4224e19b2b05d5cbe730a7ba0e34ec7585de0043" + dependencies: + prop-types "^15.5.8" + react-redux@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.1.1.tgz#88e368682c7fa80e34e055cd7ac56f5936b0f52f" @@ -16507,55 +16485,81 @@ slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" -slate-base64-serializer@^0.2.111: - version "0.2.111" - resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.111.tgz#22ba7d32aa4650f6bbd25c26ffe11f5d021959d6" - integrity sha512-pEsbxz4msVSCCCkn7rX+lHXxUj/oddcR4VsIYwWeQQLm9Uw7Ovxja4rQ/hVFcQqoU2DIjITRwBR9pv3RyS+PZQ== +slate-base64-serializer@^0.2.36: + version "0.2.102" + resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.102.tgz#05cdb9149172944b55c8d0a0d14b4499a1c3b5a2" dependencies: isomorphic-base64 "^1.0.2" -slate-dev-environment@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/slate-dev-environment/-/slate-dev-environment-0.2.2.tgz#bd8946e1fe4cf5447060c84a362a1d026ed8b77f" - integrity sha512-JZ09llrRQu6JUsLJCUlGC0lB1r1qIAabAkSd454iyYBq6lDuY//Bypi3Jo8yzIfzZ4+mRLdQvl9e8MbeM9l48Q== +slate-dev-environment@^0.1.2, slate-dev-environment@^0.1.4: + version "0.1.6" + resolved "https://registry.yarnpkg.com/slate-dev-environment/-/slate-dev-environment-0.1.6.tgz#ff22b40ef4cc890ff7706b6b657abc276782424f" dependencies: is-in-browser "^1.1.3" -slate-hotkeys@^0.2.9: - version "0.2.9" - resolved "https://registry.yarnpkg.com/slate-hotkeys/-/slate-hotkeys-0.2.9.tgz#0cc9eb750a49ab9ef11601305b7c82b5402348e3" - integrity sha512-y+C/s5vJEmBxo8fIqHmUcdViGwALL/A6Qow3sNG1OHYD5SI11tC2gfYtGbPh+2q0H7O4lufffCmFsP5bMaDHqA== - dependencies: - is-hotkey "0.1.4" - slate-dev-environment "^0.2.2" +slate-dev-logger@^0.1.39, slate-dev-logger@^0.1.43: + version "0.1.43" + resolved "https://registry.yarnpkg.com/slate-dev-logger/-/slate-dev-logger-0.1.43.tgz#77f6ca7207fcbf453a5516f3aa8b19794d1d26dc" -slate-plain-serializer@0.7.10, slate-plain-serializer@^0.7.10: - version "0.7.10" - resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.10.tgz#bc4a6942cf52fde826019bb1095dffd0dac8cc08" - integrity sha512-/QvMCQ0F3NzbnuoW+bxsLIChPdRgxBjQeGhYhpRGTVvlZCLOmfDvavhN6fHsuEwkvdwOmocNF30xT1WVlmibYg== +slate-hotkeys@^0.1.2: + version "0.1.4" + resolved "https://registry.yarnpkg.com/slate-hotkeys/-/slate-hotkeys-0.1.4.tgz#5b10b2a178affc60827f9284d4c0a5d7e5041ffe" + dependencies: + is-hotkey "^0.1.1" + slate-dev-environment "^0.1.4" -slate-prop-types@^0.5.41: +slate-plain-serializer@0.5.41, slate-plain-serializer@^0.5.17: version "0.5.41" - resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.5.41.tgz#42031881e2fef4fa978a96b9aad84b093b4a5219" - integrity sha512-fLcXlugO9btF5b/by+dA+n8fn2mET75VGWltqFNxGdl6ncyBtrGspWA7mLVRFSqQWOS/Ig4A3URCRumOBBCUfQ== + resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.5.41.tgz#dc2d219602c2cb8dc710ac660e108f3b3cc4dc80" + dependencies: + slate-dev-logger "^0.1.43" + +slate-prism@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/slate-prism/-/slate-prism-0.5.0.tgz#009eb74fea38ad76c64db67def7ea0884917adec" + dependencies: + prismjs "^1.13.0" -slate-react-placeholder@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/slate-react-placeholder/-/slate-react-placeholder-0.2.8.tgz#973ac47c9a518a1418e89b6021b0f6120c07ce6f" - integrity sha512-CZZSg5usE2ZY/AYg06NVcL9Wia6hD/Mg0w4D4e9rPh6hkkFJg8LZXYMRz+6Q4v1dqHmzRsZ2Ixa0jRuiKXsMaQ== +slate-prop-types@^0.4.34: + version "0.4.67" + resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.4.67.tgz#c6aa74195466546a44fcb85d1c7b15fefe36ce6b" -slate@0.47.8: - version "0.47.8" - resolved "https://registry.yarnpkg.com/slate/-/slate-0.47.8.tgz#1e987b74d8216d44ec56154f0e6d3c722ce21e6e" - integrity sha512-/Jt0eq4P40qZvtzeKIvNb+1N97zVICulGQgQoMDH0TI8h8B+5kqa1YeckRdRnuvfYJm3J/9lWn2V3J1PrF+hag== +slate-react@0.12.11: + version "0.12.11" + resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.12.11.tgz#6d83e604634704757690a57dbd6aab282a964ad3" + dependencies: + debug "^3.1.0" + get-window "^1.1.1" + is-window "^1.0.2" + keycode "^2.1.2" + lodash "^4.1.1" + prop-types "^15.5.8" + react-immutable-proptypes "^2.1.0" + react-portal "^3.1.0" + selection-is-backward "^1.0.0" + slate-base64-serializer "^0.2.36" + slate-dev-environment "^0.1.2" + slate-dev-logger "^0.1.39" + slate-hotkeys "^0.1.2" + slate-plain-serializer "^0.5.17" + slate-prop-types "^0.4.34" + +slate-schema-violations@^0.1.12: + version "0.1.39" + resolved "https://registry.yarnpkg.com/slate-schema-violations/-/slate-schema-violations-0.1.39.tgz#854ab5624136419cef4c803b1823acabe11f1c15" + +slate@0.33.8: + version "0.33.8" + resolved "https://registry.yarnpkg.com/slate/-/slate-0.33.8.tgz#c2cd9906c446d010b15e9e28f6d1a01792c7a113" dependencies: debug "^3.1.0" direction "^0.1.5" esrever "^0.2.0" + is-empty "^1.0.0" is-plain-object "^2.0.4" lodash "^4.17.4" - tiny-invariant "^1.0.1" - tiny-warning "^0.0.3" + slate-dev-logger "^0.1.39" + slate-schema-violations "^0.1.12" type-of "^2.0.1" slice-ansi@0.0.4: @@ -17462,20 +17466,14 @@ tiny-emitter@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" -tiny-invariant@^1.0.1, tiny-invariant@^1.0.2: - version "1.0.6" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" - integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== +tiny-invariant@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.4.tgz#346b5415fd93cb696b0c4e8a96697ff590f92463" tiny-relative-date@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07" -tiny-warning@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-0.0.3.tgz#1807eb4c5f81784a6354d58ea1d5024f18c6c81f" - integrity sha512-r0SSA5Y5IWERF9Xh++tFPx0jITBgGggOsRLDWWew6YRw/C2dr4uNO1fw1vanrBmHsICmPyMLNBZboTlxUmUuaA== - tiny-warning@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28" @@ -17784,7 +17782,6 @@ type-name@^2.0.1: type-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/type-of/-/type-of-2.0.1.tgz#e72a1741896568e9f628378d816d6912f7f23972" - integrity sha1-5yoXQYllaOn2KDeNgW1pEvfyOXI= typed-styles@^0.0.7: version "0.0.7" @@ -18631,6 +18628,7 @@ wrap-ansi@^5.1.0: wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= write-file-atomic@2.4.1: version "2.4.1" From f7de64bd15b21a53e4af7a1622e2a9b2123abcd8 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 14:04:08 +0200 Subject: [PATCH 81/87] Chore: Changelog for v6.4.0-beta1 (#19171) --- CHANGELOG.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0f51776e0e..99124fabcc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # 6.4.0 (unreleased) +# 6.4.0-beta1 (2019-09-17) + +### Features / Enhancements +* **API**: Readonly datasources should not be created via the API. [#19006](https://github.com/grafana/grafana/pull/19006), [@papagian](https://github.com/papagian) +* **Alerting**: Include configured AlertRuleTags in Webhooks notifier. [#18233](https://github.com/grafana/grafana/pull/18233), [@dominic-miglar](https://github.com/dominic-miglar) +* **Annotations**: Add annotations support to Loki. [#18949](https://github.com/grafana/grafana/pull/18949), [@aocenas](https://github.com/aocenas) +* **Annotations**: Use a single row to represent a region. [#17673](https://github.com/grafana/grafana/pull/17673), [@ryantxu](https://github.com/ryantxu) +* **Auth**: Allow inviting existing users when login form is disabled. [#19048](https://github.com/grafana/grafana/pull/19048), [@548017](https://github.com/548017) +* **Azure Monitor**: Add support for cross resource queries. [#19115](https://github.com/grafana/grafana/pull/19115), [@sunker](https://github.com/sunker) +* **CLI**: Allow installing custom binary plugins. [#17551](https://github.com/grafana/grafana/pull/17551), [@aocenas](https://github.com/aocenas) +* **Dashboard**: Adds Logs Panel (alpha) as visualization option for Dashboards. [#18641](https://github.com/grafana/grafana/pull/18641), [@hugohaggmark](https://github.com/hugohaggmark) +* **Dashboard**: Reuse query results between panels . [#16660](https://github.com/grafana/grafana/pull/16660), [@ryantxu](https://github.com/ryantxu) +* **Dashboard**: Set time to to 23:59:59 when setting To time using calendar. [#18595](https://github.com/grafana/grafana/pull/18595), [@simPod](https://github.com/simPod) +* **DataLinks**: Add DataLinks support to Gauge, BarGauge and SingleStat2 panel. [#18605](https://github.com/grafana/grafana/pull/18605), [@ryantxu](https://github.com/ryantxu) +* **DataLinks**: Enable access to labels & field names. [#18918](https://github.com/grafana/grafana/pull/18918), [@torkelo](https://github.com/torkelo) +* **DataLinks**: Enable multiple data links per panel. [#18434](https://github.com/grafana/grafana/pull/18434), [@dprokop](https://github.com/dprokop) +* **Docker**: switch docker image to alpine base with phantomjs support. [#18468](https://github.com/grafana/grafana/pull/18468), [@DanCech](https://github.com/DanCech) +* **Elasticsearch**: allow templating queries to order by doc_count. [#18870](https://github.com/grafana/grafana/pull/18870), [@hackery](https://github.com/hackery) +* **Explore**: Add throttling when doing live queries. [#19085](https://github.com/grafana/grafana/pull/19085), [@aocenas](https://github.com/aocenas) +* **Explore**: Adds ability to go back to dashboard, optionally with query changes. [#17982](https://github.com/grafana/grafana/pull/17982), [@kaydelaney](https://github.com/kaydelaney) +* **Explore**: Reduce default time range to last hour. [#18212](https://github.com/grafana/grafana/pull/18212), [@davkal](https://github.com/davkal) +* **Gauge/BarGauge**: Support decimals for min/max. [#18368](https://github.com/grafana/grafana/pull/18368), [@ryantxu](https://github.com/ryantxu) +* **Graph**: New series override transform constant that renders a single point as a line across the whole graph. [#19102](https://github.com/grafana/grafana/pull/19102), [@davkal](https://github.com/davkal) +* **Image rendering**: Add deprecation warning when PhantomJS is used for rendering images. [#18933](https://github.com/grafana/grafana/pull/18933), [@papagian](https://github.com/papagian) +* **InfluxDB**: Enable interpolation within ad-hoc filter values. [#18077](https://github.com/grafana/grafana/pull/18077), [@kvc-code](https://github.com/kvc-code) +* **LDAP**: Allow an user to be synchronized against LDAP. [#18976](https://github.com/grafana/grafana/pull/18976), [@gotjosh](https://github.com/gotjosh) +* **Ldap**: Add ldap debug page. [#18759](https://github.com/grafana/grafana/pull/18759), [@peterholmberg](https://github.com/peterholmberg) +* **Loki**: Remove prefetching of default label values. [#18213](https://github.com/grafana/grafana/pull/18213), [@davkal](https://github.com/davkal) +* **Metrics**: Add failed alert notifications metric. [#18089](https://github.com/grafana/grafana/pull/18089), [@koorgoo](https://github.com/koorgoo) +* **OAuth**: Support JMES path lookup when retrieving user email. [#14683](https://github.com/grafana/grafana/pull/14683), [@bobmshannon](https://github.com/bobmshannon) +* **OAuth**: return GitLab groups as a part of user info (enable team sync). [#18388](https://github.com/grafana/grafana/pull/18388), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **Panels**: Add unit for electrical charge - ampere-hour. [#18950](https://github.com/grafana/grafana/pull/18950), [@anirudh-ramesh](https://github.com/anirudh-ramesh) +* **Plugin**: AzureMonitor - Reapply MetricNamespace support. [#17282](https://github.com/grafana/grafana/pull/17282), [@raphaelquati](https://github.com/raphaelquati) +* **Plugins**: better warning when plugins fail to load. [#18671](https://github.com/grafana/grafana/pull/18671), [@ryantxu](https://github.com/ryantxu) +* **Postgres**: Add support for scram sha 256 authentication. [#18397](https://github.com/grafana/grafana/pull/18397), [@nonamef](https://github.com/nonamef) +* **RemoteCache**: Support SSL with Redis. [#18511](https://github.com/grafana/grafana/pull/18511), [@kylebrandt](https://github.com/kylebrandt) +* **SingleStat**: The gauge option in now disabled/hidden (unless it's an old panel with it already enabled) . [#18610](https://github.com/grafana/grafana/pull/18610), [@ryantxu](https://github.com/ryantxu) +* **Stackdriver**: Add extra alignment period options. [#18909](https://github.com/grafana/grafana/pull/18909), [@sunker](https://github.com/sunker) +* **Units**: Add South African Rand (ZAR) to currencies. [#18893](https://github.com/grafana/grafana/pull/18893), [@jeteon](https://github.com/jeteon) +* **Units**: Adding T,P,E,Z,and Y bytes. [#18706](https://github.com/grafana/grafana/pull/18706), [@chiqomar](https://github.com/chiqomar) + +### Bug Fixes +* **Alerting**: Notification is sent when state changes from no_data to ok. [#18920](https://github.com/grafana/grafana/pull/18920), [@papagian](https://github.com/papagian) +* **Alerting**: fix duplicate alert states when the alert fails to save to the database. [#18216](https://github.com/grafana/grafana/pull/18216), [@kylebrandt](https://github.com/kylebrandt) +* **Alerting**: fix response popover prompt when add notification channels. [#18967](https://github.com/grafana/grafana/pull/18967), [@lzdw](https://github.com/lzdw) +* **CloudWatch**: Fix alerting for queries with Id (using GetMetricData). [#17899](https://github.com/grafana/grafana/pull/17899), [@alex-berger](https://github.com/alex-berger) +* **Explore**: Fix auto completion on label values for Loki. [#18988](https://github.com/grafana/grafana/pull/18988), [@aocenas](https://github.com/aocenas) +* **Explore**: Fixes crash using back button with a zoomed in graph. [#19122](https://github.com/grafana/grafana/pull/19122), [@hugohaggmark](https://github.com/hugohaggmark) +* **Explore**: Fixes so queries in Explore are only run if Graph/Table is shown. [#19000](https://github.com/grafana/grafana/pull/19000), [@hugohaggmark](https://github.com/hugohaggmark) +* **MSSQL**: Change connectionstring to URL format to fix using passwords with semicolon. [#18384](https://github.com/grafana/grafana/pull/18384), [@Russiancold](https://github.com/Russiancold) +* **MSSQL**: Fix memory leak when debug enabled. [#19049](https://github.com/grafana/grafana/pull/19049), [@briangann](https://github.com/briangann) +* **Provisioning**: Allow escaping literal '$' with '$$' in configs to avoid interpolation. [#18045](https://github.com/grafana/grafana/pull/18045), [@kylebrandt](https://github.com/kylebrandt) +* **TimePicker**: Fixes hiding time picker dropdown in FireFox. [#19154](https://github.com/grafana/grafana/pull/19154), [@hugohaggmark](https://github.com/hugohaggmark) + ## Breaking changes ### Annotations From 32417e1388dfa885831c1ffeac434f8baa60f904 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue, 17 Sep 2019 15:25:48 +0200 Subject: [PATCH 82/87] Devenv: create slow_proxy_mac (#19174) --- .../docker/blocks/slow_proxy_mac/Dockerfile | 7 +++++ .../blocks/slow_proxy_mac/docker-compose.yaml | 6 ++++ devenv/docker/blocks/slow_proxy_mac/main.go | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 devenv/docker/blocks/slow_proxy_mac/Dockerfile create mode 100644 devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml create mode 100644 devenv/docker/blocks/slow_proxy_mac/main.go diff --git a/devenv/docker/blocks/slow_proxy_mac/Dockerfile b/devenv/docker/blocks/slow_proxy_mac/Dockerfile new file mode 100644 index 000000000000..e553cb6727c6 --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/Dockerfile @@ -0,0 +1,7 @@ + +FROM golang:latest +ADD main.go / +WORKDIR / +RUN go build -o main . +EXPOSE 3011 +ENTRYPOINT ["/main"] diff --git a/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml b/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml new file mode 100644 index 000000000000..47347042df7f --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml @@ -0,0 +1,6 @@ + slow_proxy_mac: + build: docker/blocks/slow_proxy_mac + ports: + - '3011:3011' + environment: + ORIGIN_SERVER: 'http://host.docker.internal:9090/' diff --git a/devenv/docker/blocks/slow_proxy_mac/main.go b/devenv/docker/blocks/slow_proxy_mac/main.go new file mode 100644 index 000000000000..dece2525c139 --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "time" +) + +func main() { + origin := os.Getenv("ORIGIN_SERVER") + if origin == "" { + origin = "http://host.docker.internal:9090/" + } + + sleep := time.Minute + + originURL, _ := url.Parse(origin) + proxy := httputil.NewSingleHostReverseProxy(originURL) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("sleeping for %s then proxying request: %s", sleep.String(), r.RequestURI) + <-time.After(sleep) + proxy.ServeHTTP(w, r) + }) + + log.Fatal(http.ListenAndServe(":3011", nil)) +} From 79cb51c93f1ecfe5ae4323a98b8b811937e206b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 17 Sep 2019 15:51:45 +0200 Subject: [PATCH 83/87] Docs: What's new in 6.4 update (#19175) * Updated whats new article * Fixed reports image * fixes to whats new doc * Minor changes --- docs/sources/guides/whats-new-in-v6-4.md | 124 +++++++++++++++++++++-- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/docs/sources/guides/whats-new-in-v6-4.md b/docs/sources/guides/whats-new-in-v6-4.md index 2c7eab68741d..e105c641ea9e 100644 --- a/docs/sources/guides/whats-new-in-v6-4.md +++ b/docs/sources/guides/whats-new-in-v6-4.md @@ -16,24 +16,132 @@ For all details please read the full [CHANGELOG.md](https://github.com/grafana/g ## Highlights -Grafana 6.4 comes with a lot of new features and enhancements backed with tons of work around the data models and query -execution that is going to enable powerful future capabilities. Some of those new capabilities can already be seen in -this release, like sharing query results between panels. +Grafana 6.4 comes with a lot of new features and enhancements backed with tons of work around the data models and query execution that is going to enable powerful future capabilities. +Some of those new capabilities can already be seen in this release, like sharing query results between panels. - [**Explore:** Go back to dashboard (with query changes)]({{< relref "#go-back-to-dashboard-from-explore" >}}) - [**Explore:** Live tailing improvements]({{< relref "#live-tailing-improvements" >}}) -- [**Loki:** Show logs as annotations in dashboard graphs]({{< relref "#loki-annotations" >}}) -- [**Loki:** Use Loki in dashboard panels]({{< relref "#loki-in-dashbaords" >}}) +- **Loki:** Show logs as annotations in dashboard graphs +- **Loki:** Use Loki in dashboard panels - [**Panels:** New logs panel]({{< relref "#new-logs-panel" >}}) - [**Panels:** Data links improvements]({{< relref "#data-links-improvements" >}}) -- [**Dashboard:** Share query results between panels]({{< relref "#share-query-results" >}}) +- **Graph:** Series override to turn constant (point) into a line +- [**Dashboard:** Share query results between panels]({{< relref "#share-query-results-between-panels" >}}) - [**Plugins:** Alpha version of grafana-toolkit]({{< relref "#alpha-version-of-grafana-toolkit" >}}) +- [**Azure:** Query over multiple resources in Azure Monitor]({{< relref "#query-over-multiple-resources-in-azure-monitor" >}}) - [**ImageRendering:** PhantomJS deprecation]({{< relref "#phantomjs-deprecation" >}}) - [**Docker:** Alpine based docker image]({{< relref "#alpine-based-docker-image" >}}) -- [**LDAP Debug View**: Reporting]({{< relref "#ldap-debug-view" >}}) +- [**LDAP:** Debug UI]({{< relref "#ldap-debug-ui" >}}) - [**Enterprise**: Reporting]({{< relref "#reporting" >}}) - [**Enterprise**: GitLab OAuth Team Sync support]({{< relref "#gitlab-oauth-team-sync-support" >}}) - [**Enterprise**: Teams & LDAP Improvements]({{< relref "#ldap-teams" >}}) -More details on the above coming soon! +### Go back to dashboard from Explore + +To help accelerate workflows that involve regularly switching from Explore to a dashboard and vice-versa, we've added the ability to return to the origin dashboard +after navigating to Explore from the panel's dropdown. + +{{< docs-imagebox img="/img/docs/v60/explore_panel_menu.png" caption="Screenshot of the new Explore Icon" >}} + +After you've navigated to Explore, you should notice a "Back" button in the Explore toolbar. + + + +Simply clicking the button will return you to the origin dashboard, or, if you'd like to bring changes you make in Explore back to the dashboard, simply click +the arrow next to the button to reveal a "Return to panel with changes" menu item. + + + +### Live tailing improvements + +With 6.4 version you can now pause the live tail view to see the last 1000 lines of logs without being interrupted by new logs coming in. You can either pause manually with pause button or the live tailing will automatically pause when you scroll up to see older logs. To resume you just hit the resume button to continue live tailing. + +We also introduced some performance optimizations to allow live tailing of higher throughput log streams and various UI fixes and improvements like more consistent styling and fresh logs highlighting. + + + +### New Logs Panel + +A new panel specifically to show logs is added in this release. It’s in early alpha state so you have to enable alpha panels (in config file) to be able to try it out. + +## Data Links improvements + +With Grafana 6.3 we introduced a new way of creating [Data Links](https://grafana.com/blog/2019/08/27/new-in-grafana-6.3-easy-to-use-data-links/). +Grafana 6.4 improves Data Links and adds them to the Gauge and Bar Gauge and panels. + +With Data Links you can define dynamic links to other dashboards and systems. The link can now reference template variables and query results like series name & labels, field name, value and time. + +Read more about Data Links and what you can do with them in [documentation](https://grafana.com/docs/features/panels/graph/#data-link) + +## Share query results between panels + +Grafana 6.4 continues the work started in 6.3 of creating a data model and query execution lifecycle that can support robust analytics and streaming. These changes are mostly structural and lay the foundation for powerful features in future releases. + +The first new feature all these changes have enabled is the ability to share query results between panels. So for example if you have an expensive query you can visualize the same results in a graph, table and singlestat panel. To reuse another panel’s query result select the data source named `-- Dashboard --` and then select the panel. + +To make the sharing of query results even more powerful we are introducing a transformation step as well that allows you to select specific parts of the query result and transform it. This new transformation feature is in [alpha](https://grafana.com/docs/installation/configuration/#enable-alpha) state and has to be enabled in the config file. + +DataFrame, our primary data model, has now a [columnar](https://en.wikipedia.org/wiki/Column-oriented_DBMS) layout. This +will support easier frontend processing. The DataSource query interface has been updated to better support streaming. +The result can now either return a `Promise` or `Observable`. Be on the lookout for more on live data +streaming in the future! + +## Alpha version of grafana-toolkit + +[grafana-toolkit](https://www.npmjs.com/package/@grafana/toolkit/v/6.4.0-beta.1) is our attempt to simplify the life of plugin developers. It’s a CLI that helps them focus on the core value of their plugin rather than the ceremony around setting up the environment, configs, tests and builds. It’s available as an NPM package under `next` tag. + +You can read more about the grafana-toolkit [in the Readme](https://github.com/grafana/grafana/blob/master/packages/grafana-toolkit/README.md) and play with it by trying out our [react panel](https://github.com/grafana/simple-react-panel) or [angular panel](https://github.com/grafana/simple-angular-panel) templates. + +## Query over multiple resources in Azure Monitor + +Up until now it has only been possible to query over one resource in one subscription in the Azure Monitor datasource. In Grafana 6.4, the Azure Monitor query editor contains a new option called Query Mode which allows for querying over multiple resources in multiple subscriptions. This can be very useful in many situations, e.g cpu credits consumed by all virtual machines over many subscriptions. + +Alerting is yet to be implemented for Multiple Resources queries. + +## PhantomJS deprecation + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, have been deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. + +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). + +## Alpine based docker image + +Grafana’s docker image is now based on Alpine 3.10 and should from now on report zero vulnerabilities when scanning the image for security vulnerabilities. + +## LDAP Debug UI + +After listening to customer feedback, we have been working at improving the experience to set up authentication and synchronization with LDAP. We're happy to present the new LDAP Debug View. + +You'll be able to see how a user authenticating with LDAP would be mapped and whether your LDAP integration is working correctly. Furthermore, it provides a simpler method to test your integration with LDAP server(s) and have a clear view of how attributes are mapped between both systems. + +The feature is currently limited to Grafana Server Admins. + +For more information on how to use this new feature, follow the [guide](TBD). + +## Grafana Enterprise + +### Reporting + +A common request from Enterprise users have been to be able to set up reporting for Grafana, and now it’s here. A report is simply a PDF of a Grafana dashboard, outside of just generating a PDF you can set up a schedule so that you can get the report emailed to yourself (or whoever is interested) whenever it suits you. + +This feature is currently limited to Organization Admins. + +{{< docs-imagebox img="/img/docs/v64/reports.jpeg" max-width="500px" caption="Reporting" >}} + +### GitLab OAuth Team Sync support + +GitLab OAuth gets support for Team Sync, making it possible to synchronize your GitLab Groups with Teams in Grafana. + +[Read more about Team Sync](https://grafana.com/docs/auth/team-sync/) + +## Upgrading + +See [upgrade notes](/docs/installation/upgrading/#upgrading-to-v6-4). + +## Changelog + +Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. + + + From ca9a46309a2568ae58acdc929d42cdc794a0f46f Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 15:54:30 +0200 Subject: [PATCH 84/87] Chore: Update version to next (#19169) --- lerna.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lerna.json b/lerna.json index 30f4e4728fd6..28adbc5e7544 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "npmClient": "yarn", "useWorkspaces": true, "packages": ["packages/*"], - "version": "6.4.0-pre" + "version": "6.5.0-pre" } diff --git a/package.json b/package.json index a53ab845d1ea..381021a34533 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "license": "Apache-2.0", "private": true, "name": "grafana", - "version": "6.4.0-pre", + "version": "6.5.0-pre", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" From fcec156cb34e98b3a40aad20ce8ba1b1a25de23e Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Sep 2019 17:37:35 +0200 Subject: [PATCH 85/87] Chore: Update latest.json (#19177) --- latest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/latest.json b/latest.json index 40d26f46fe2d..b5abed00efaf 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { "stable": "6.3.5", - "testing": "6.3.5" + "testing": "6.4.0-beta1" } From 75bf31b5c7c3ea3626969446d956872f76cb51b9 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 17 Sep 2019 19:24:03 +0200 Subject: [PATCH 86/87] docs: image rendering (#19183) Adds a new "Image Rendering" page in Administration section. Updates configuration page with rendering settings and also default.ini and sample.ini. Updates and cleanup pages that referencing image rendering. Ref #18914 --- conf/defaults.ini | 4 +- conf/sample.ini | 4 +- .../sources/administration/image_rendering.md | 51 +++++++++++++++++++ docs/sources/alerting/notifications.md | 4 +- docs/sources/installation/configuration.md | 11 ++++ docs/sources/reference/sharing.md | 4 +- 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 docs/sources/administration/image_rendering.md diff --git a/conf/defaults.ini b/conf/defaults.ini index c825f2374d2b..3bb57c3ac720 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -669,8 +669,10 @@ container_name = # does not require any configuration [rendering] -# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer +# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer. +# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service. server_url = +# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/. callback_url = [panels] diff --git a/conf/sample.ini b/conf/sample.ini index b215a4fb02d8..7e8d96065b9c 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -598,8 +598,10 @@ # does not require any configuration [rendering] -# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer +# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer. +# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service. ;server_url = +# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/. ;callback_url = [enterprise] diff --git a/docs/sources/administration/image_rendering.md b/docs/sources/administration/image_rendering.md new file mode 100644 index 000000000000..1e1801e69874 --- /dev/null +++ b/docs/sources/administration/image_rendering.md @@ -0,0 +1,51 @@ ++++ +title = "Image Rendering" +description = "" +keywords = ["grafana", "image", "rendering", "phantomjs"] +type = "docs" +aliases = ["/installation/image-rendering"] +[menu.docs] +parent = "admin" +weight = 8 ++++ + +# Image Rendering + +Grafana supports rendering of panels and dasnhboards as PNG-images. + +When an image is being rendered the PNG-image is temporary written to the filesystem, i.e. a sub-directory of Grafana's [data](/installation/configuration/#data) directory named `png`. + +A background job runs each 10 minutes and will remove temporary images. You can configure how long time an image should be stored before being removed by configuring the [temp-data-lifetime](/installation/configuration/#temp-data-lifetime) setting. + +## Rendering methods + +### PhantomJS + +> PhantomJS is deprecated since Grafana v6.4 and will be removed in a future release. Please migrate to Grafana image renderer plugin or remote rendering service. + +[PhantomJS](https://phantomjs.org/) have been the only supported and default image renderer since Grafana v2.x and is shipped with Grafana. + +Please note that for OSX and Windows, you will need to ensure that a phantomjs binary is available under tools/phantomjs/phantomjs. For Linux, a phantomjs binary is included - however, you should ensure that any required libraries, e.g. libfontconfig1, are available. + +### Grafana image renderer plugin + +The [Grafana image renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) is a plugin that runs on the backend and handles rendering panels and dashboards as PNG-images using headless chrome. + +You can install it using grafana-cli: + +```bash +grafana-cli plugins install grafana-image-renderer +``` + +For further information and instructions refer to the [plugin details](https://grafana.com/grafana/plugins/grafana-image-renderer). + +### Remote rendering service + +The [Grafana image renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) can also be run as a remote HTTP rendering service. In this setup Grafana will render an image by making a HTTP request to the remote rendering service, which in turn render the image and returns it back in the HTTP response to Grafana. + +To configure Grafana to use a remote HTTP rendering service, please refer to [rendering](/installation/configuration/#rendering) configuration section. + +## Alerting and render limits + +Alert notifications can include images, but rendering many images at the same time can overload the server where the renderer is running. For instructions of how to configure this, see [concurrent_render_limit](/installation/configuration/#concurrent-render-limit). + diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 7ec53e402b16..e2231986eece 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -190,7 +190,9 @@ Webhook | `webhook` | yes, external only | yes # Enable images in notifications {#external-image-store} -Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports +Grafana can render the panel associated with the alert rule as a PNG image and include that in the notification. Read more about the requirements and how to configure image rendering [here](/administration/image_rendering/). + +Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports Amazon S3, Webdav, Google Cloud Storage and Azure Blob Storage. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file. Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If you're using local image uploader, your Grafana instance need to be accessible by the internet. diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 2920cd2c235b..ade8cb296ce5 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -688,6 +688,17 @@ Default setting for alert notification timeout. Default value is `30` Default setting for max attempts to sending alert notifications. Default value is `3` +## [rendering] + +Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer. + +### server_url + +URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service. + +### callback_url + +If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/. ## [panels] diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index aebf76090f3a..7f9599d00406 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -39,12 +39,12 @@ Click a panel title to open the panel menu, then click share in the panel menu t ### Direct Link Rendered Image -You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `tools/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig1) are available. +You also get a link to render a PNG image of the panel. Useful if you want to share an image of the panel. Read more about the requirements and how to configure image rendering [here](/administration/image_rendering/). Example of a link to a server-side rendered PNG: ```bash -http://play.grafana.org/render/dashboard-solo/db/grafana-play-home?orgId=1&panelId=4&from=1499272191563&to=1499279391563&width=1000&height=500&tz=UTC%2B02%3A00&timeout=5000 +https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=1568719680173&to=1568726880174&panelId=4&fullscreen ``` #### Query String Parameters For Server-Side Rendered Images From dd794625ddb937557295fa006f9222753ebc6dd1 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 17 Sep 2019 19:28:35 +0200 Subject: [PATCH 87/87] API: adds redirect helper to simplify http redirects (#19180) --- pkg/api/common.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/api/common.go b/pkg/api/common.go index 7973c72c8fa8..74bf42de7a81 100644 --- a/pkg/api/common.go +++ b/pkg/api/common.go @@ -135,3 +135,15 @@ func Respond(status int, body interface{}) *NormalResponse { header: make(http.Header), } } + +type RedirectResponse struct { + location string +} + +func (r *RedirectResponse) WriteTo(ctx *m.ReqContext) { + ctx.Redirect(r.location) +} + +func Redirect(location string) *RedirectResponse { + return &RedirectResponse{location: location} +}