From 0006765a40a6214ec848a31ab0ddd8308d369d99 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Tue, 4 May 2021 16:31:25 +0200 Subject: [PATCH] Alerting: Run queries (#33423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * alertingqueryrunner first edition * added so we always set name and uid when changing datasource. * wip. * wip * added support for canceling requests. * util for getting time ranges for expression queries * remove logs, store data in state * added structure for marble testing. * change so the expression buttons doesnt submit form. * fixed run button. * replaced mocks with implementation that will set default query + expression. * fixed so we set a datasource name for the default expression rule. * improving expression guard. * Update public/app/features/alerting/components/AlertingQueryEditor.tsx Co-authored-by: Hugo Häggmark * fixed some nits. * some refactoring after feedback. * use grafanthemev2 * rename grafanatheme * fixing so we convert to correct relative time range. * added some more tests. * fixing so duplicating query works. * added some more tests without marbles. * small refactoring to share code between runRequest and alerting query runner. Co-authored-by: Marcus Andersson Co-authored-by: Hugo Häggmark --- .../src/datetime/rangeutil.test.ts | 38 +++ .../grafana-data/src/datetime/rangeutil.ts | 7 +- packages/grafana-data/src/types/time.ts | 12 + .../components/AlertingQueryEditor.tsx | 110 ++++++-- .../alerting/components/AlertingQueryRows.tsx | 12 +- .../state/AlertingQueryRunner.test.ts | 243 ++++++++++++++++++ .../alerting/state/AlertingQueryRunner.ts | 186 ++++++++++++++ .../components/rule-editor/AlertRuleForm.tsx | 7 +- .../alerting/unified/mocks/grafana-queries.ts | 57 ---- .../alerting/unified/utils/rule-form.ts | 69 ++++- .../alerting/unified/utils/timeRange.test.ts | 204 +++++++++++++++ .../alerting/unified/utils/timeRange.ts | 76 ++++++ .../components/ClassicConditions.tsx | 2 +- .../expressions/components/Condition.tsx | 2 +- public/app/features/expressions/guards.ts | 17 +- .../app/features/query/state/QueryRunner.ts | 21 +- .../query/state/processing/canceler.ts | 14 + .../query/state/processing/revision.ts | 16 ++ public/app/features/query/state/runRequest.ts | 11 +- public/app/types/unified-alerting-dto.ts | 2 +- 20 files changed, 979 insertions(+), 127 deletions(-) create mode 100644 public/app/features/alerting/state/AlertingQueryRunner.test.ts create mode 100644 public/app/features/alerting/state/AlertingQueryRunner.ts delete mode 100644 public/app/features/alerting/unified/mocks/grafana-queries.ts create mode 100644 public/app/features/alerting/unified/utils/timeRange.test.ts create mode 100644 public/app/features/alerting/unified/utils/timeRange.ts create mode 100644 public/app/features/query/state/processing/canceler.ts create mode 100644 public/app/features/query/state/processing/revision.ts diff --git a/packages/grafana-data/src/datetime/rangeutil.test.ts b/packages/grafana-data/src/datetime/rangeutil.test.ts index da1606b6ac7..18c0bbd9a75 100644 --- a/packages/grafana-data/src/datetime/rangeutil.test.ts +++ b/packages/grafana-data/src/datetime/rangeutil.test.ts @@ -1,4 +1,6 @@ +import { TimeRange } from '../types/time'; import { dateTime, rangeUtil } from './index'; +import { timeRangeToRelative } from './rangeutil'; describe('Range Utils', () => { describe('relative time', () => { @@ -58,4 +60,40 @@ describe('Range Utils', () => { expect(timeRange.to.valueOf()).toEqual(dateTime('2021-04-20T15:55:00Z').valueOf()); }); }); + + describe('timeRangeToRelative', () => { + it('should convert now-15m to relaitve time range', () => { + const now = dateTime('2021-04-20T15:55:00Z'); + const timeRange: TimeRange = { + from: dateTime(now).subtract(15, 'minutes'), + to: now, + raw: { + from: 'now-15m', + to: 'now', + }, + }; + + const relativeTimeRange = timeRangeToRelative(timeRange, now); + + expect(relativeTimeRange.from).toEqual(900); + expect(relativeTimeRange.to).toEqual(0); + }); + + it('should convert now-2w, now-1w to relative range', () => { + const now = dateTime('2021-04-20T15:55:00Z'); + const timeRange: TimeRange = { + from: dateTime(now).subtract(2, 'weeks'), + to: dateTime(now).subtract(1, 'week'), + raw: { + from: 'now-2w', + to: 'now-1w', + }, + }; + + const relativeTimeRange = timeRangeToRelative(timeRange, now); + + expect(relativeTimeRange.from).toEqual(1209600); + expect(relativeTimeRange.to).toEqual(604800); + }); + }); }); diff --git a/packages/grafana-data/src/datetime/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts index b1ce1432990..2279ee3074e 100644 --- a/packages/grafana-data/src/datetime/rangeutil.ts +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -439,10 +439,9 @@ export function roundInterval(interval: number) { * * @internal */ -export function timeRangeToRelative(timeRange: TimeRange): RelativeTimeRange { - const now = dateTime().unix(); - const from = (now - timeRange.from.unix()) / 1000; - const to = (now - timeRange.to.unix()) / 1000; +export function timeRangeToRelative(timeRange: TimeRange, now: DateTime = dateTime()): RelativeTimeRange { + const from = now.unix() - timeRange.from.unix(); + const to = now.unix() - timeRange.to.unix(); return { from, diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts index 4f7e93f0f89..d85ffc4a1e7 100644 --- a/packages/grafana-data/src/types/time.ts +++ b/packages/grafana-data/src/types/time.ts @@ -60,3 +60,15 @@ export function getDefaultTimeRange(): TimeRange { raw: { from: 'now-6h', to: 'now' }, }; } + +/** + * Returns the default realtive time range. + * + * @public + */ +export function getDefaultRelativeTimeRange(): RelativeTimeRange { + return { + from: 21600, + to: 0, + }; +} diff --git a/public/app/features/alerting/components/AlertingQueryEditor.tsx b/public/app/features/alerting/components/AlertingQueryEditor.tsx index 07bedc56869..80af1fb08ec 100644 --- a/public/app/features/alerting/components/AlertingQueryEditor.tsx +++ b/public/app/features/alerting/components/AlertingQueryEditor.tsx @@ -1,15 +1,25 @@ import React, { PureComponent } from 'react'; import { css } from '@emotion/css'; -import { DataSourceApi, GrafanaTheme } from '@grafana/data'; +import { + DataQuery, + getDefaultRelativeTimeRange, + GrafanaTheme2, + LoadingState, + PanelData, + RelativeTimeRange, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Button, HorizontalGroup, Icon, stylesFactory, Tooltip } from '@grafana/ui'; -import { config, getDataSourceSrv } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { AlertingQueryRows } from './AlertingQueryRows'; import { dataSource as expressionDatasource, ExpressionDatasourceUID } from '../../expressions/ExpressionDatasource'; import { getNextRefIdChar } from 'app/core/utils/query'; import { defaultCondition } from '../../expressions/utils/expressionTypes'; import { ExpressionQueryType } from '../../expressions/types'; import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { AlertingQueryRunner } from '../state/AlertingQueryRunner'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { isExpressionQuery } from 'app/features/expressions/guards'; interface Props { value?: GrafanaQuery[]; @@ -17,33 +27,44 @@ interface Props { } interface State { - defaultDataSource?: DataSourceApi; + panelDataByRefId: Record; } export class AlertingQueryEditor extends PureComponent { + private runner: AlertingQueryRunner; + constructor(props: Props) { super(props); - this.state = {}; + this.state = { panelDataByRefId: {} }; + this.runner = new AlertingQueryRunner(); } - async componentDidMount() { - try { - const defaultDataSource = await getDataSourceSrv().get(); - this.setState({ defaultDataSource }); - } catch (error) { - console.error(error); - } + componentDidMount() { + this.runner.get().subscribe((data) => { + this.setState({ panelDataByRefId: data }); + }); } - onRunQueries = () => {}; + componentWillUnmount() { + this.runner.destroy(); + } + + onRunQueries = () => { + const { value = [] } = this.props; + this.runner.run(value); + }; + + onCancelQueries = () => { + this.runner.cancel(); + }; onDuplicateQuery = (query: GrafanaQuery) => { const { onChange, value = [] } = this.props; - onChange([...value, query]); + onChange(addQuery(value, query)); }; onNewAlertingQuery = () => { const { onChange, value = [] } = this.props; - const { defaultDataSource } = this.state; + const defaultDataSource = getDatasourceSrv().getInstanceSettings('default'); if (!defaultDataSource) { return; @@ -104,9 +125,38 @@ export class AlertingQueryEditor extends PureComponent { ); } + isRunning() { + const data = Object.values(this.state.panelDataByRefId).find((d) => Boolean(d)); + return data?.state === LoadingState.Loading; + } + + renderRunQueryButton() { + const isRunning = this.isRunning(); + const styles = getStyles(config.theme2); + + if (isRunning) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); + } + render() { const { value = [] } = this.props; - const styles = getStyles(config.theme); + const styles = getStyles(config.theme2); + return (
{ onRunQueries={this.onRunQueries} /> {this.renderAddQueryRow(styles)} + {this.renderRunQueryButton()}
); } @@ -134,33 +185,36 @@ const addQuery = ( model: { ...queryToAdd.model, hide: false, - refId: refId, - }, - relativeTimeRange: { - from: 21600, - to: 0, + refId, }, + relativeTimeRange: defaultTimeRange(queryToAdd.model), }; return [...queries, query]; }; -const getStyles = stylesFactory((theme: GrafanaTheme) => { +const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => { + if (isExpressionQuery(model)) { + return; + } + return getDefaultRelativeTimeRange(); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme2) => { return { container: css` - background-color: ${theme.colors.panelBg}; + background-color: ${theme.colors.background.primary}; height: 100%; `, - refreshWrapper: css` - display: flex; - justify-content: flex-end; + runWrapper: css` + margin-top: ${theme.spacing(1)}; `, editorWrapper: css` - border: 1px solid ${theme.colors.panelBorder}; - border-radius: ${theme.border.radius.md}; + border: 1px solid ${theme.colors.border.medium}; + border-radius: ${theme.shape.borderRadius()}; `, expressionButton: css` - margin-right: ${theme.spacing.sm}; + margin-right: ${theme.spacing(0.5)}; `, }; }); diff --git a/public/app/features/alerting/components/AlertingQueryRows.tsx b/public/app/features/alerting/components/AlertingQueryRows.tsx index 7124379115d..afb5e4a2718 100644 --- a/public/app/features/alerting/components/AlertingQueryRows.tsx +++ b/public/app/features/alerting/components/AlertingQueryRows.tsx @@ -76,6 +76,7 @@ export class AlertingQueryRows extends PureComponent { onChangeQuery(query: DataQuery, index: number) { const { queries, onQueriesChange } = this.props; + onQueriesChange( queries.map((item, itemIndex) => { if (itemIndex !== index) { @@ -112,6 +113,13 @@ export class AlertingQueryRows extends PureComponent { onQueriesChange(update); }; + onDuplicateQuery = (query: DataQuery, source: GrafanaQuery): void => { + this.props.onDuplicateQuery({ + ...source, + model: query, + }); + }; + getDataSourceSettings = (query: GrafanaQuery): DataSourceInstanceSettings | undefined => { return getDataSourceSrv().getInstanceSettings(query.datasourceUid); }; @@ -148,7 +156,7 @@ export class AlertingQueryRows extends PureComponent { query={query.model} onChange={(query) => this.onChangeQuery(query, index)} timeRange={ - !isExpressionQuery(query.model) + !isExpressionQuery(query.model) && query.relativeTimeRange ? rangeUtil.relativeToTimeRange(query.relativeTimeRange) : undefined } @@ -158,7 +166,7 @@ export class AlertingQueryRows extends PureComponent { : undefined } onRemoveQuery={this.onRemoveQuery} - onAddQuery={this.props.onDuplicateQuery} + onAddQuery={(duplicate) => this.onDuplicateQuery(duplicate, query)} onRunQuery={this.props.onRunQueries} queries={queries} /> diff --git a/public/app/features/alerting/state/AlertingQueryRunner.test.ts b/public/app/features/alerting/state/AlertingQueryRunner.test.ts new file mode 100644 index 00000000000..9e250aba688 --- /dev/null +++ b/public/app/features/alerting/state/AlertingQueryRunner.test.ts @@ -0,0 +1,243 @@ +import { + ArrayVector, + DataFrame, + DataFrameJSON, + Field, + FieldType, + getDefaultRelativeTimeRange, + LoadingState, + rangeUtil, +} from '@grafana/data'; +import { FetchResponse } from '@grafana/runtime'; +import { BackendSrv } from 'app/core/services/backend_srv'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { Observable, of, throwError } from 'rxjs'; +import { delay, take } from 'rxjs/operators'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { AlertingQueryResponse, AlertingQueryRunner } from './AlertingQueryRunner'; + +describe('AlertingQueryRunner', () => { + it('should successfully map response and return panel data by refId', async () => { + const response = createFetchResponse({ + results: { + A: { frames: [createDataFrameJSON([1, 2, 3])] }, + B: { frames: [createDataFrameJSON([5, 6])] }, + }, + }); + + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => of(response), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(1))).toEmitValuesWith((values) => { + const [data] = values; + expect(data).toEqual({ + A: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238, 1620051632238], + values: [1, 2, 3], + }), + ], + structureRev: 1, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + B: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238], + values: [5, 6], + }), + ], + structureRev: 1, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + }); + }); + }); + + it('should successfully map response with sliding relative time range', async () => { + const response = createFetchResponse({ + results: { + A: { frames: [createDataFrameJSON([1, 2, 3])] }, + B: { frames: [createDataFrameJSON([5, 6])] }, + }, + }); + + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => of(response), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(1))).toEmitValuesWith((values) => { + const [data] = values; + const relativeA = rangeUtil.timeRangeToRelative(data.A.timeRange); + const relativeB = rangeUtil.timeRangeToRelative(data.B.timeRange); + const expected = getDefaultRelativeTimeRange(); + + expect(relativeA).toEqual(expected); + expect(relativeB).toEqual(expected); + }); + }); + + it('should emit loading state if response is slower then 200ms', async () => { + const response = createFetchResponse({ + results: { + A: { frames: [createDataFrameJSON([1, 2, 3])] }, + B: { frames: [createDataFrameJSON([5, 6])] }, + }, + }); + + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => of(response).pipe(delay(210)), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(2))).toEmitValuesWith((values) => { + const [loading, data] = values; + + expect(loading.A.state).toEqual(LoadingState.Loading); + expect(loading.B.state).toEqual(LoadingState.Loading); + + expect(data).toEqual({ + A: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238, 1620051632238], + values: [1, 2, 3], + }), + ], + structureRev: 2, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + B: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238], + values: [5, 6], + }), + ], + structureRev: 2, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + }); + }); + }); + + it('should emit error state if fetch request fails', async () => { + const error = new Error('could not query data'); + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => throwError(error), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(1))).toEmitValuesWith((values) => { + const [data] = values; + + expect(data.A.state).toEqual(LoadingState.Error); + expect(data.A.error).toEqual(error); + + expect(data.B.state).toEqual(LoadingState.Error); + expect(data.B.error).toEqual(error); + }); + }); +}); + +type MockBackendSrvConfig = { + fetch: () => Observable>; +}; + +const mockBackendSrv = ({ fetch }: MockBackendSrvConfig): BackendSrv => { + return ({ + fetch, + resolveCancelerIfExists: jest.fn(), + } as unknown) as BackendSrv; +}; + +const expectDataFrameWithValues = ({ time, values }: { time: number[]; values: number[] }): DataFrame => { + return { + fields: [ + { + config: {}, + entities: {}, + name: 'time', + state: null, + type: FieldType.time, + values: new ArrayVector(time), + } as Field, + { + config: {}, + entities: {}, + name: 'value', + state: null, + type: FieldType.number, + values: new ArrayVector(values), + } as Field, + ], + length: values.length, + }; +}; + +const createDataFrameJSON = (values: number[]): DataFrameJSON => { + const startTime = 1620051602238; + const timeValues = values.map((_, index) => startTime + (index + 1) * 10000); + + return { + schema: { + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'value', type: FieldType.number }, + ], + }, + data: { + values: [timeValues, values], + }, + }; +}; + +const createQuery = (refId: string): GrafanaQuery => { + return { + refId, + queryType: '', + datasourceUid: '', + model: { refId }, + relativeTimeRange: getDefaultRelativeTimeRange(), + }; +}; diff --git a/public/app/features/alerting/state/AlertingQueryRunner.ts b/public/app/features/alerting/state/AlertingQueryRunner.ts new file mode 100644 index 00000000000..ff8be173079 --- /dev/null +++ b/public/app/features/alerting/state/AlertingQueryRunner.ts @@ -0,0 +1,186 @@ +import { merge, Observable, of, OperatorFunction, ReplaySubject, timer, Unsubscribable } from 'rxjs'; +import { catchError, map, mapTo, share, takeUntil } from 'rxjs/operators'; +import { v4 as uuidv4 } from 'uuid'; +import { + dataFrameFromJSON, + DataFrameJSON, + getDefaultTimeRange, + LoadingState, + PanelData, + rangeUtil, + TimeRange, +} from '@grafana/data'; +import { FetchResponse, toDataQueryError } from '@grafana/runtime'; +import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; +import { preProcessPanelData } from 'app/features/query/state/runRequest'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { getTimeRangeForExpression } from '../unified/utils/timeRange'; +import { isExpressionQuery } from 'app/features/expressions/guards'; +import { setStructureRevision } from 'app/features/query/state/processing/revision'; +import { cancelNetworkRequestsOnUnsubscribe } from 'app/features/query/state/processing/canceler'; + +export interface AlertingQueryResult { + frames: DataFrameJSON[]; +} + +export interface AlertingQueryResponse { + results: Record; +} +export class AlertingQueryRunner { + private subject: ReplaySubject>; + private subscription?: Unsubscribable; + private lastResult: Record; + + constructor(private backendSrv = getBackendSrv()) { + this.subject = new ReplaySubject(1); + this.lastResult = {}; + } + + get(): Observable> { + return this.subject.asObservable(); + } + + run(queries: GrafanaQuery[]) { + if (queries.length === 0) { + const empty = initialState(queries, LoadingState.Done); + return this.subject.next(empty); + } + + this.subscription = runRequest(this.backendSrv, queries).subscribe({ + next: (dataPerQuery) => { + const nextResult = applyChange(dataPerQuery, (refId, data) => { + const previous = this.lastResult[refId]; + const preProcessed = preProcessPanelData(data, previous); + return setStructureRevision(preProcessed, previous); + }); + + this.lastResult = nextResult; + this.subject.next(this.lastResult); + }, + error: (error: Error) => { + this.lastResult = mapErrorToPanelData(this.lastResult, error); + this.subject.next(this.lastResult); + }, + }); + } + + cancel() { + if (!this.subscription) { + return; + } + this.subscription.unsubscribe(); + + let requestIsRunning = false; + + const nextResult = applyChange(this.lastResult, (refId, data) => { + if (data.state === LoadingState.Loading) { + requestIsRunning = true; + } + + return { + ...data, + state: LoadingState.Done, + }; + }); + + if (requestIsRunning) { + this.subject.next(nextResult); + } + } + + destroy() { + if (this.subject) { + this.subject.complete(); + } + this.cancel(); + } +} + +const runRequest = (backendSrv: BackendSrv, queries: GrafanaQuery[]): Observable> => { + const initial = initialState(queries, LoadingState.Loading); + const request = { + data: { data: queries }, + url: '/api/v1/eval', + method: 'POST', + requestId: uuidv4(), + }; + + const runningRequest = backendSrv.fetch(request).pipe( + mapToPanelData(initial), + catchError((error) => of(mapErrorToPanelData(initial, error))), + cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId), + share() + ); + + return merge(timer(200).pipe(mapTo(initial), takeUntil(runningRequest)), runningRequest); +}; + +const initialState = (queries: GrafanaQuery[], state: LoadingState): Record => { + return queries.reduce((dataByQuery: Record, query) => { + dataByQuery[query.refId] = { + state, + series: [], + timeRange: getTimeRange(query, queries), + }; + + return dataByQuery; + }, {}); +}; + +const getTimeRange = (query: GrafanaQuery, queries: GrafanaQuery[]): TimeRange => { + if (isExpressionQuery(query.model)) { + const relative = getTimeRangeForExpression(query.model, queries); + return rangeUtil.relativeToTimeRange(relative); + } + + if (!query.relativeTimeRange) { + console.warn(`Query with refId: ${query.refId} did not have any relative time range, using default.`); + return getDefaultTimeRange(); + } + + return rangeUtil.relativeToTimeRange(query.relativeTimeRange); +}; + +const mapToPanelData = ( + dataByQuery: Record +): OperatorFunction, Record> => { + return map((response) => { + const { data } = response; + const results: Record = {}; + + for (const [refId, result] of Object.entries(data.results)) { + results[refId] = { + timeRange: dataByQuery[refId].timeRange, + state: LoadingState.Done, + series: result.frames.map(dataFrameFromJSON), + }; + } + + return results; + }); +}; + +const mapErrorToPanelData = (lastResult: Record, error: Error): Record => { + const queryError = toDataQueryError(error); + + return applyChange(lastResult, (refId, data) => { + return { + ...data, + state: LoadingState.Error, + error: queryError, + }; + }); +}; + +const applyChange = ( + initial: Record, + change: (refId: string, data: PanelData) => PanelData +): Record => { + const nextResult: Record = {}; + + for (const [refId, data] of Object.entries(initial)) { + nextResult[refId] = change(refId, data); + } + + return nextResult; +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index 20f73a6e5a3..9f27dc83dc0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -16,7 +16,7 @@ import { saveRuleFormAction } from '../../state/actions'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { useDispatch } from 'react-redux'; import { useCleanup } from 'app/core/hooks/useCleanup'; -import { rulerRuleToFormValues, defaultFormValues } from '../../utils/rule-form'; +import { rulerRuleToFormValues, defaultFormValues, getDefaultQueries } from '../../utils/rule-form'; import { Link } from 'react-router-dom'; type Props = { @@ -31,7 +31,10 @@ export const AlertRuleForm: FC = ({ existing }) => { if (existing) { return rulerRuleToFormValues(existing); } - return defaultFormValues; + return { + ...defaultFormValues, + queries: getDefaultQueries(), + }; }, [existing]); const formAPI = useForm({ diff --git a/public/app/features/alerting/unified/mocks/grafana-queries.ts b/public/app/features/alerting/unified/mocks/grafana-queries.ts deleted file mode 100644 index 61a191f7801..00000000000 --- a/public/app/features/alerting/unified/mocks/grafana-queries.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const SAMPLE_QUERIES = [ - { - refId: 'A', - queryType: '', - relativeTimeRange: { - from: 30, - to: 0, - }, - datasourceUid: '000000004', - model: { - intervalMs: 1000, - maxDataPoints: 100, - pulseWave: { - offCount: 6, - offValue: 1, - onCount: 6, - onValue: 10, - timeStep: 5, - }, - refId: 'A', - scenarioId: 'predictable_pulse', - stringInput: '', - }, - }, - { - refId: 'B', - queryType: '', - relativeTimeRange: { - from: 0, - to: 0, - }, - datasourceUid: '-100', - model: { - conditions: [ - { - evaluator: { - params: [3], - type: 'gt', - }, - operator: { - type: 'and', - }, - query: { - params: ['A'], - }, - reducer: { - type: 'last', - }, - }, - ], - intervalMs: 1000, - maxDataPoints: 100, - refId: 'B', - type: 'classic_conditions', - }, - }, -]; diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index d27ead130bb..f76d2e3b0c2 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -1,12 +1,17 @@ +import { getDefaultTimeRange, rangeUtil } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { ExpressionDatasourceID, ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; +import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { Annotations, GrafanaAlertState, + GrafanaQuery, Labels, PostableRuleGrafanaRuleDTO, RulerAlertingRuleDTO, } from 'app/types/unified-alerting-dto'; -import { SAMPLE_QUERIES } from '../mocks/grafana-queries'; +import { EvalFunction } from '../../state/alertDef'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { isGrafanaRulesSource } from './datasource'; import { arrayToRecord, recordToArray } from './misc'; @@ -21,7 +26,7 @@ export const defaultFormValues: RuleFormValues = Object.freeze({ // threshold folder: null, - queries: SAMPLE_QUERIES, // @TODO remove the sample eventually + queries: [], condition: '', noDataState: GrafanaAlertState.NoData, execErrState: GrafanaAlertState.Alerting, @@ -115,3 +120,63 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF } } } + +export const getDefaultQueries = (): GrafanaQuery[] => { + const dataSource = getDataSourceSrv().getInstanceSettings('default'); + + if (!dataSource) { + return [getDefaultExpression('A')]; + } + + const timeRange = getDefaultTimeRange(); + const relativeTimeRange = rangeUtil.timeRangeToRelative(timeRange); + + return [ + { + refId: 'A', + datasourceUid: dataSource.uid, + queryType: '', + relativeTimeRange, + model: { + refId: 'A', + hide: false, + }, + }, + getDefaultExpression('B'), + ]; +}; + +const getDefaultExpression = (refId: string): GrafanaQuery => { + const model: ExpressionQuery = { + refId, + hide: false, + type: ExpressionQueryType.classic, + datasource: ExpressionDatasourceID, + conditions: [ + { + type: 'query', + evaluator: { + params: [3], + type: EvalFunction.IsAbove, + }, + operator: { + type: 'and', + }, + query: { + params: ['A'], + }, + reducer: { + params: [], + type: 'last', + }, + }, + ], + }; + + return { + refId, + datasourceUid: ExpressionDatasourceUID, + queryType: '', + model, + }; +}; diff --git a/public/app/features/alerting/unified/utils/timeRange.test.ts b/public/app/features/alerting/unified/utils/timeRange.test.ts new file mode 100644 index 00000000000..4fdf5cdaac3 --- /dev/null +++ b/public/app/features/alerting/unified/utils/timeRange.test.ts @@ -0,0 +1,204 @@ +import { ReducerID } from '@grafana/data'; +import { getTimeRangeForExpression } from './timeRange'; +import { defaultCondition } from 'app/features/expressions/utils/expressionTypes'; +import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; + +describe('timeRange', () => { + describe('getTimeRangeForExpression', () => { + describe('classic condition', () => { + it('should return referenced query timeRange for classic condition', () => { + const expressionQuery: GrafanaQuery = { + refId: 'B', + queryType: 'expression', + datasourceUid: '-100', + model: { + queryType: 'query', + datasource: '__expr__', + refId: 'B', + conditions: [{ ...defaultCondition, query: { params: ['A'] } }], + type: ExpressionQueryType.classic, + } as ExpressionQuery, + }; + const query: GrafanaQuery = { + refId: 'A', + relativeTimeRange: { from: 300, to: 0 }, + queryType: 'query', + datasourceUid: 'dsuid', + model: { refId: 'A' }, + }; + const queries: GrafanaQuery[] = [query, expressionQuery]; + + expect(getTimeRangeForExpression(expressionQuery.model as ExpressionQuery, queries)).toEqual({ + from: 300, + to: 0, + }); + }); + + it('should return the min and max time range', () => { + const expressionQuery: GrafanaQuery = { + refId: 'C', + queryType: 'expression', + datasourceUid: '-100', + model: { + queryType: 'query', + datasource: '__expr__', + refId: 'C', + conditions: [ + { ...defaultCondition, query: { params: ['A'] } }, + { ...defaultCondition, query: { params: ['B'] } }, + ], + type: ExpressionQueryType.classic, + } as ExpressionQuery, + }; + const queryA: GrafanaQuery = { + refId: 'A', + relativeTimeRange: { from: 300, to: 0 }, + datasourceUid: 'dsuid', + model: { refId: 'A' }, + queryType: 'query', + }; + const queryB: GrafanaQuery = { + refId: 'B', + relativeTimeRange: { from: 600, to: 300 }, + datasourceUid: 'dsuid', + model: { refId: 'B' }, + queryType: 'query', + }; + const queries: GrafanaQuery[] = [queryA, queryB, expressionQuery]; + + expect(getTimeRangeForExpression(expressionQuery.model as ExpressionQuery, queries)).toEqual({ + from: 600, + to: 0, + }); + }); + }); + }); + describe('math', () => { + it('should get timerange for referenced query', () => { + const expressionQuery: GrafanaQuery = { + refId: 'B', + queryType: 'expression', + datasourceUid: '-100', + model: { + queryType: 'query', + datasource: '__expr__', + refId: 'B', + expression: '$A > 10', + type: ExpressionQueryType.math, + } as ExpressionQuery, + }; + + const query: GrafanaQuery = { + refId: 'A', + datasourceUid: 'dsuid', + relativeTimeRange: { from: 300, to: 0 }, + model: { refId: 'A' }, + queryType: 'query', + }; + + expect(getTimeRangeForExpression(expressionQuery.model as ExpressionQuery, [expressionQuery, query])); + }); + + it('should get time ranges for multiple referenced queries', () => { + const expressionQuery: GrafanaQuery = { + refId: 'C', + queryType: 'expression', + datasourceUid: '-100', + model: { + queryType: 'query', + datasource: '__expr__', + refId: 'C', + expression: '$A > 10 && $queryB > 20', + type: ExpressionQueryType.math, + } as ExpressionQuery, + }; + + const queryA: GrafanaQuery = { + refId: 'A', + relativeTimeRange: { from: 300, to: 0 }, + datasourceUid: 'dsuid', + model: { refId: 'A' }, + queryType: 'query', + }; + + const queryB: GrafanaQuery = { + refId: 'queryB', + relativeTimeRange: { from: 600, to: 300 }, + datasourceUid: 'dsuid', + model: { refId: 'queryB' }, + queryType: 'query', + }; + + expect( + getTimeRangeForExpression(expressionQuery.model as ExpressionQuery, [expressionQuery, queryA, queryB]) + ).toEqual({ from: 600, to: 0 }); + }); + }); + + describe('resample', () => { + it('should get referenced timerange for resample expression', () => { + const expressionQuery: GrafanaQuery = { + refId: 'B', + queryType: 'expression', + datasourceUid: '-100', + model: { + queryType: 'query', + datasource: '__expr__', + refId: 'B', + expression: 'A', + type: ExpressionQueryType.resample, + window: '10s', + } as ExpressionQuery, + }; + + const queryA: GrafanaQuery = { + refId: 'A', + relativeTimeRange: { from: 300, to: 0 }, + datasourceUid: 'dsuid', + model: { refId: 'A' }, + queryType: 'query', + }; + + const queries = [queryA, expressionQuery]; + + expect(getTimeRangeForExpression(expressionQuery.model as ExpressionQuery, queries)).toEqual({ + from: 300, + to: 0, + }); + }); + }); + + describe('reduce', () => { + it('should get referenced timerange for reduce expression', () => { + const expressionQuery: GrafanaQuery = { + refId: 'B', + queryType: 'expression', + datasourceUid: '-100', + model: { + queryType: 'query', + datasource: '__expr__', + refId: 'B', + expression: 'A', + type: ExpressionQueryType.reduce, + reducer: ReducerID.max, + } as ExpressionQuery, + }; + + const queryA: GrafanaQuery = { + refId: 'A', + relativeTimeRange: { from: 300, to: 0 }, + datasourceUid: 'dsuid', + model: { refId: 'A' }, + queryType: 'query', + }; + + const queries = [queryA, expressionQuery]; + + expect(getTimeRangeForExpression(expressionQuery.model as ExpressionQuery, queries)).toEqual({ + from: 300, + to: 0, + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/timeRange.ts b/public/app/features/alerting/unified/utils/timeRange.ts new file mode 100644 index 00000000000..d5331ab9eb7 --- /dev/null +++ b/public/app/features/alerting/unified/utils/timeRange.ts @@ -0,0 +1,76 @@ +import { RelativeTimeRange } from '@grafana/data'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { ExpressionQuery, ExpressionQueryType } from '../../../expressions/types'; + +const FALL_BACK_TIME_RANGE = { from: 21600, to: 0 }; + +export const getTimeRangeForExpression = (query: ExpressionQuery, queries: GrafanaQuery[]): RelativeTimeRange => { + const referencedRefIds: string[] | undefined = getReferencedIds(query, queries); + + if (!referencedRefIds) { + return FALL_BACK_TIME_RANGE; + } + + const { from, to } = getTimeRanges(referencedRefIds, queries); + + if (!from.length && !to.length) { + return FALL_BACK_TIME_RANGE; + } + + return { + from: Math.max(...from), + to: Math.min(...to), + }; +}; + +const getReferencedIds = (model: ExpressionQuery, queries: GrafanaQuery[]): string[] | undefined => { + switch (model.type) { + case ExpressionQueryType.classic: + return getReferencedIdsForClassicCondition(model); + case ExpressionQueryType.math: + return getReferencedIdsForMath(model, queries); + case ExpressionQueryType.resample: + case ExpressionQueryType.reduce: + return getReferencedIdsForReduce(model); + } +}; + +const getReferencedIdsForClassicCondition = (model: ExpressionQuery) => { + return model.conditions?.map((condition) => { + return condition.query.params[0]; + }); +}; + +const getTimeRanges = (referencedRefIds: string[], queries: GrafanaQuery[]) => { + let from: number[] = []; + let to = [FALL_BACK_TIME_RANGE.to]; + for (const referencedRefIdsKey of referencedRefIds) { + const query = queries.find((query) => query.refId === referencedRefIdsKey); + + if (!query || !query.relativeTimeRange) { + continue; + } + from.push(query.relativeTimeRange.from); + to.push(query.relativeTimeRange.to); + } + + return { + from, + to, + }; +}; + +const getReferencedIdsForMath = (model: ExpressionQuery, queries: GrafanaQuery[]) => { + return ( + queries + // filter queries of type query and filter expression on if it includes any refIds + .filter((q) => q.queryType === 'query' && model.expression?.includes(q.refId)) + .map((q) => { + return q.refId; + }) + ); +}; + +const getReferencedIdsForReduce = (model: ExpressionQuery) => { + return model.expression ? [model.expression] : undefined; +}; diff --git a/public/app/features/expressions/components/ClassicConditions.tsx b/public/app/features/expressions/components/ClassicConditions.tsx index 8d4b77e0106..1903bdd3c8b 100644 --- a/public/app/features/expressions/components/ClassicConditions.tsx +++ b/public/app/features/expressions/components/ClassicConditions.tsx @@ -76,7 +76,7 @@ export const ClassicConditions: FC = ({ onChange, query, refIds }) => { - diff --git a/public/app/features/expressions/components/Condition.tsx b/public/app/features/expressions/components/Condition.tsx index 3c9e0b00a14..dd2a8129a3d 100644 --- a/public/app/features/expressions/components/Condition.tsx +++ b/public/app/features/expressions/components/Condition.tsx @@ -122,7 +122,7 @@ export const Condition: FC = ({ condition, index, onChange, onRemoveCondi /> ) : null} - diff --git a/public/app/features/expressions/guards.ts b/public/app/features/expressions/guards.ts index 42236d4f830..2e36ed498dc 100644 --- a/public/app/features/expressions/guards.ts +++ b/public/app/features/expressions/guards.ts @@ -1,7 +1,20 @@ import { DataQuery } from '@grafana/data'; import { ExpressionDatasourceID } from './ExpressionDatasource'; -import { ExpressionQuery } from './types'; +import { ExpressionQuery, ExpressionQueryType } from './types'; export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is ExpressionQuery => { - return dataQuery?.datasource === ExpressionDatasourceID; + if (!dataQuery) { + return false; + } + + if (dataQuery.datasource === ExpressionDatasourceID) { + return true; + } + + const expression = dataQuery as ExpressionQuery; + + if (typeof expression.type !== 'string') { + return false; + } + return Object.values(ExpressionQueryType).includes(expression.type); }; diff --git a/public/app/features/query/state/QueryRunner.ts b/public/app/features/query/state/QueryRunner.ts index 9d36f4ca906..115592a6436 100644 --- a/public/app/features/query/state/QueryRunner.ts +++ b/public/app/features/query/state/QueryRunner.ts @@ -8,8 +8,6 @@ import { QueryRunnerOptions, QueryRunner as QueryRunnerSrv, LoadingState, - compareArrayValues, - compareDataFrameStructures, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -17,6 +15,7 @@ import { cloneDeep } from 'lodash'; import { from, Observable, ReplaySubject, Unsubscribable } from 'rxjs'; import { first } from 'rxjs/operators'; import { getNextRequestId } from './PanelQueryRunner'; +import { setStructureRevision } from './processing/revision'; import { preProcessPanelData, runRequest } from './runRequest'; export class QueryRunner implements QueryRunnerSrv { @@ -102,23 +101,7 @@ export class QueryRunner implements QueryRunnerSrv { this.subscription = runRequest(ds, request).subscribe({ next: (data) => { const results = preProcessPanelData(data, this.lastResult); - - // Indicate if the structure has changed since the last query - let structureRev = 1; - if (this.lastResult?.structureRev && this.lastResult.series) { - structureRev = this.lastResult.structureRev; - const sameStructure = compareArrayValues( - results.series, - this.lastResult.series, - compareDataFrameStructures - ); - if (!sameStructure) { - structureRev++; - } - } - results.structureRev = structureRev; - this.lastResult = results; - + this.lastResult = setStructureRevision(results, this.lastResult); // Store preprocessed query results for applying overrides later on in the pipeline this.subject.next(this.lastResult); }, diff --git a/public/app/features/query/state/processing/canceler.ts b/public/app/features/query/state/processing/canceler.ts new file mode 100644 index 00000000000..3e0498b108e --- /dev/null +++ b/public/app/features/query/state/processing/canceler.ts @@ -0,0 +1,14 @@ +import { BackendSrv } from 'app/core/services/backend_srv'; +import { MonoTypeOperatorFunction } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +export function cancelNetworkRequestsOnUnsubscribe( + backendSrv: BackendSrv, + requestId: string | undefined +): MonoTypeOperatorFunction { + return finalize(() => { + if (requestId) { + backendSrv.resolveCancelerIfExists(requestId); + } + }); +} diff --git a/public/app/features/query/state/processing/revision.ts b/public/app/features/query/state/processing/revision.ts new file mode 100644 index 00000000000..db61cf303bf --- /dev/null +++ b/public/app/features/query/state/processing/revision.ts @@ -0,0 +1,16 @@ +import { compareArrayValues, compareDataFrameStructures, PanelData } from '@grafana/data'; + +export const setStructureRevision = (result: PanelData, lastResult: PanelData | undefined) => { + let structureRev = 1; + + if (lastResult?.structureRev && lastResult.series) { + structureRev = lastResult.structureRev; + const sameStructure = compareArrayValues(result.series, lastResult.series, compareDataFrameStructures); + if (!sameStructure) { + structureRev++; + } + } + + result.structureRev = structureRev; + return result; +}; diff --git a/public/app/features/query/state/runRequest.ts b/public/app/features/query/state/runRequest.ts index e451a052224..b3f085129f2 100644 --- a/public/app/features/query/state/runRequest.ts +++ b/public/app/features/query/state/runRequest.ts @@ -1,7 +1,7 @@ // Libraries import { from, merge, Observable, of, timer } from 'rxjs'; import { isString, map as isArray } from 'lodash'; -import { catchError, finalize, map, mapTo, share, takeUntil, tap } from 'rxjs/operators'; +import { catchError, map, mapTo, share, takeUntil, tap } from 'rxjs/operators'; // Utils & Services import { backendSrv } from 'app/core/services/backend_srv'; // Types @@ -28,6 +28,7 @@ import { ExpressionDatasourceUID, } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionQuery } from 'app/features/expressions/types'; +import { cancelNetworkRequestsOnUnsubscribe } from './processing/canceler'; type MapOfResponsePackets = { [str: string]: DataQueryResponse }; @@ -155,7 +156,7 @@ export function runRequest( tap(emitDataRequestEvent(datasource)), // finalize is triggered when subscriber unsubscribes // This makes sure any still running network requests are cancelled - finalize(cancelNetworkRequestsOnUnsubscribe(request)), + cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId), // this makes it possible to share this observable in takeUntil share() ); @@ -166,12 +167,6 @@ export function runRequest( return merge(timer(200).pipe(mapTo(state.panelData), takeUntil(dataObservable)), dataObservable); } -function cancelNetworkRequestsOnUnsubscribe(req: DataQueryRequest) { - return () => { - backendSrv.resolveCancelerIfExists(req.requestId); - }; -} - export function callQueryMethod( datasource: DataSourceApi, request: DataQueryRequest, diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 55692e191a5..07f122997e5 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -95,7 +95,7 @@ export enum GrafanaAlertState { export interface GrafanaQuery { refId: string; queryType: string; - relativeTimeRange: RelativeTimeRange; + relativeTimeRange?: RelativeTimeRange; datasourceUid: string; model: DataQuery; }