import { defaultsDeep } from 'lodash'; import { Observable, TimeoutError, lastValueFrom, of, throwError } from 'rxjs'; import { delay, take, timeout } from 'rxjs/operators'; import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { DataFrame, DataFrameJSON, DataSourceInstanceSettings, Field, FieldType, LoadingState, getDefaultRelativeTimeRange, rangeUtil, } from '@grafana/data'; import { DataSourceSrv, DataSourceWithBackend, FetchResponse } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { DataQuery } from '@grafana/schema'; import { BackendSrv } from 'app/core/services/backend_srv'; import { EXTERNAL_VANILLA_ALERTMANAGER_UID, mockDataSources, } from 'app/features/alerting/unified/components/settings/__mocks__/server'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { AlertingQueryResponse, AlertingQueryRunner } from './AlertingQueryRunner'; setupMswServer(); 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])] }, }, }); setupDataSources(...Object.values(mockDataSources)); const runner = new AlertingQueryRunner( mockBackendSrv({ fetch: () => of(response), }) ); const data = runner.get(); runner.run([createQuery('A'), createQuery('B')], 'B'); await expect(data.pipe(take(1))).toEmitValuesWith((values) => { const [data] = values; expect(data).toEqual({ A: { annotations: [], state: LoadingState.Done, errors: [], 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, errors: [], 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), }), mockDataSourceSrv() ); const data = runner.get(); runner.run([createQuery('A'), createQuery('B')], 'B'); await expect(data.pipe(take(1))).toEmitValuesWith((values) => { const [data] = values; // these test are flakey since the absolute computed "timeRange" can differ from the relative "defaultRelativeTimeRange" // so instead we will check if the size of the timeranges match const relativeA = rangeUtil.timeRangeToRelative(data.A.timeRange); const relativeB = rangeUtil.timeRangeToRelative(data.B.timeRange); const defaultRange = getDefaultRelativeTimeRange(); expect(relativeA.from - defaultRange.from).toEqual(relativeA.to - defaultRange.to); expect(relativeB.from - defaultRange.from).toEqual(relativeB.to - defaultRange.to); }); }); 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')], '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, errors: [], 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, errors: [], 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')], '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); }); }); it('should not push any values if all queries fail filterQuery check', async () => { const runner = new AlertingQueryRunner( mockBackendSrv({ fetch: () => throwError(new Error("shouldn't happen")), }), mockDataSourceSrv({ filterQuery: () => false }) ); const data = runner.get(); runner.run([createQuery('A'), createQuery('B')], 'B'); await expect(lastValueFrom(data.pipe(timeout(200)))).rejects.toThrow(TimeoutError); }); it('should skip hidden queries and descendant nodes', async () => { const results = createFetchResponse({ results: { C: { frames: [createDataFrameJSON([1, 2, 3])] }, }, }); const runner = new AlertingQueryRunner( mockBackendSrv({ fetch: () => of(results), }), mockDataSourceSrv({ filterQuery: (model: AlertDataQuery) => model.hide !== true }) ); const data = runner.get(); runner.run( [ createQuery('A', { model: { refId: 'A', hide: true, }, }), createQuery('B', { model: { refId: 'B', hide: false, }, }), createQuery('C', { model: { refId: 'C', }, }), ], 'B' ); await expect(data.pipe(take(1))).toEmitValuesWith((values) => { const [loading, _data] = values; expect(loading.A).toBeUndefined(); expect(loading.B).toBeUndefined(); expect(loading.C.state).toEqual(LoadingState.Done); }); }); }); type MockBackendSrvConfig = { fetch: () => Observable>; }; const mockBackendSrv = ({ fetch }: MockBackendSrvConfig): BackendSrv => { return { fetch, resolveCancelerIfExists: jest.fn(), } as unknown as BackendSrv; }; interface MockOpts { filterQuery?: (query: DataQuery) => boolean; } const mockDataSourceSrv = (opts?: MockOpts) => { const ds = new DataSourceWithBackend({} as unknown as DataSourceInstanceSettings); ds.filterQuery = opts?.filterQuery; return { get: () => Promise.resolve(ds), } as unknown as DataSourceSrv; }; const expectDataFrameWithValues = ({ time, values }: { time: number[]; values: number[] }): DataFrame => { return { fields: [ { config: {}, entities: {}, name: 'time', state: null, type: FieldType.time, values: time, } as Field, { config: {}, entities: {}, name: 'value', state: null, type: FieldType.number, values: values, } as Field, ], length: values.length, }; }; describe('prepareQueries', () => { it('should skip node that fail to link', async () => { const queries = [ createQuery('A', { model: { refId: 'A', hide: true, // this node will be omitted }, }), createQuery('B', { model: { refId: 'B', hide: false, // this node will _not_ be omitted }, }), createExpression('C', { model: { refId: 'C', type: ExpressionQueryType.math, expression: '$A', // this node will be omitted because it is a descendant of A (omitted) }, }), createExpression('D', { model: { refId: 'D', type: ExpressionQueryType.math, expression: '$ZZZ', // this node will be omitted, ref does not exist }, }), createExpression('E', { model: { refId: 'E', type: ExpressionQueryType.math, expression: '$B', // this node will be omitted, ref does not exist }, }), createExpression('F', { model: { refId: 'F', type: ExpressionQueryType.math, expression: '$D', // this node will be omitted, because D is broken too }, }), ]; const runner = new AlertingQueryRunner( mockBackendSrv({ fetch: () => of(), }), mockDataSourceSrv({ filterQuery: (model: AlertDataQuery) => model.hide !== true }) ); const queriesToRun = await runner.prepareQueries(queries); expect(queriesToRun).toHaveLength(2); expect(queriesToRun[0]).toStrictEqual(queries[1]); expect(queriesToRun[1]).toStrictEqual(queries[4]); }); }); 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, options?: Partial>): AlertQuery => { return defaultsDeep(options, { refId, queryType: '', datasourceUid: EXTERNAL_VANILLA_ALERTMANAGER_UID, model: { refId }, relativeTimeRange: getDefaultRelativeTimeRange(), }); }; const createExpression = ( refId: string, options?: Partial> ): AlertQuery => { return defaultsDeep(options, { refId, queryType: '', datasourceUid: EXTERNAL_VANILLA_ALERTMANAGER_UID, model: { refId, datasource: ExpressionDatasourceRef }, relativeTimeRange: getDefaultRelativeTimeRange(), }); };