Alerting: Run queries (#33423)

* 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 <hugo.haggmark@grafana.com>

* 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 <marcus.andersson@grafana.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
This commit is contained in:
Peter Holmberg 2021-05-04 16:31:25 +02:00 committed by GitHub
parent 9510c4f112
commit 0006765a40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 979 additions and 127 deletions

View File

@ -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);
});
});
});

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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<string, PanelData>;
}
export class AlertingQueryEditor extends PureComponent<Props, State> {
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<Props, State> {
);
}
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 (
<div className={styles.runWrapper}>
<Button icon="fa fa-spinner" type="button" variant="destructive" onClick={this.onCancelQueries}>
Cancel
</Button>
</div>
);
}
return (
<div className={styles.runWrapper}>
<Button icon="sync" type="button" onClick={this.onRunQueries}>
Run queries
</Button>
</div>
);
}
render() {
const { value = [] } = this.props;
const styles = getStyles(config.theme);
const styles = getStyles(config.theme2);
return (
<div className={styles.container}>
<AlertingQueryRows
@ -116,6 +166,7 @@ export class AlertingQueryEditor extends PureComponent<Props, State> {
onRunQueries={this.onRunQueries}
/>
{this.renderAddQueryRow(styles)}
{this.renderRunQueryButton()}
</div>
);
}
@ -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)};
`,
};
});

View File

@ -76,6 +76,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
: undefined
}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.props.onDuplicateQuery}
onAddQuery={(duplicate) => this.onDuplicateQuery(duplicate, query)}
onRunQuery={this.props.onRunQueries}
queries={queries}
/>

View File

@ -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<AlertingQueryResponse>({
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<AlertingQueryResponse>({
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<AlertingQueryResponse>({
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<FetchResponse<AlertingQueryResponse>>;
};
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(),
};
};

View File

@ -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<string, AlertingQueryResult>;
}
export class AlertingQueryRunner {
private subject: ReplaySubject<Record<string, PanelData>>;
private subscription?: Unsubscribable;
private lastResult: Record<string, PanelData>;
constructor(private backendSrv = getBackendSrv()) {
this.subject = new ReplaySubject(1);
this.lastResult = {};
}
get(): Observable<Record<string, PanelData>> {
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<Record<string, PanelData>> => {
const initial = initialState(queries, LoadingState.Loading);
const request = {
data: { data: queries },
url: '/api/v1/eval',
method: 'POST',
requestId: uuidv4(),
};
const runningRequest = backendSrv.fetch<AlertingQueryResponse>(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<string, PanelData> => {
return queries.reduce((dataByQuery: Record<string, PanelData>, 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<string, PanelData>
): OperatorFunction<FetchResponse<AlertingQueryResponse>, Record<string, PanelData>> => {
return map((response) => {
const { data } = response;
const results: Record<string, PanelData> = {};
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<string, PanelData>, error: Error): Record<string, PanelData> => {
const queryError = toDataQueryError(error);
return applyChange(lastResult, (refId, data) => {
return {
...data,
state: LoadingState.Error,
error: queryError,
};
});
};
const applyChange = (
initial: Record<string, PanelData>,
change: (refId: string, data: PanelData) => PanelData
): Record<string, PanelData> => {
const nextResult: Record<string, PanelData> = {};
for (const [refId, data] of Object.entries(initial)) {
nextResult[refId] = change(refId, data);
}
return nextResult;
};

View File

@ -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<Props> = ({ existing }) => {
if (existing) {
return rulerRuleToFormValues(existing);
}
return defaultFormValues;
return {
...defaultFormValues,
queries: getDefaultQueries(),
};
}, [existing]);
const formAPI = useForm<RuleFormValues>({

View File

@ -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',
},
},
];

View File

@ -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,
};
};

View File

@ -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,
});
});
});
});

View File

@ -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;
};

View File

@ -76,7 +76,7 @@ export const ClassicConditions: FC<Props> = ({ onChange, query, refIds }) => {
</div>
</InlineField>
</InlineFieldRow>
<Button variant="secondary" onClick={onAddCondition}>
<Button variant="secondary" type="button" onClick={onAddCondition}>
<Icon name="plus-circle" />
</Button>
</div>

View File

@ -122,7 +122,7 @@ export const Condition: FC<Props> = ({ condition, index, onChange, onRemoveCondi
/>
) : null}
<Button variant="secondary" onClick={() => onRemoveCondition(index)}>
<Button variant="secondary" type="button" onClick={() => onRemoveCondition(index)}>
<Icon name="trash-alt" />
</Button>
</InlineFieldRow>

View File

@ -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);
};

View File

@ -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);
},

View File

@ -0,0 +1,14 @@
import { BackendSrv } from 'app/core/services/backend_srv';
import { MonoTypeOperatorFunction } from 'rxjs';
import { finalize } from 'rxjs/operators';
export function cancelNetworkRequestsOnUnsubscribe<T>(
backendSrv: BackendSrv,
requestId: string | undefined
): MonoTypeOperatorFunction<T> {
return finalize(() => {
if (requestId) {
backendSrv.resolveCancelerIfExists(requestId);
}
});
}

View File

@ -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;
};

View File

@ -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,

View File

@ -95,7 +95,7 @@ export enum GrafanaAlertState {
export interface GrafanaQuery {
refId: string;
queryType: string;
relativeTimeRange: RelativeTimeRange;
relativeTimeRange?: RelativeTimeRange;
datasourceUid: string;
model: DataQuery;
}