From 19739f4af25299cbd6be0716b303d4af232d09d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 26 Apr 2021 06:13:03 +0200 Subject: [PATCH] Annotations: Adds DashboardQueryRunner (#32834) * WIP: initial commit * Fix: Fixed $timeout call when testing snapshots * Chore: reverts changes to metrics_panel_ctrl.ts * Chore: reverts changes to annotations_srv * Refactor: adds DashboardQueryRunner.run to initdashboard * Refactor: adds run to dashboard model start refresh * Refactor: move to own folder and split up into smaller files * Tests: adds tests for LegacyAnnotationQueryRunner * Tests: adds tests for AnnotationsQueryRunner * Tests: adds tests for SnapshotWorker * Refactor: renames from canRun|run to canWork|work * Tests: adds tests for AlertStatesWorker * Tests: adds tests for AnnotationsWorker * Refactor: renames operators * Refactor: renames operators * Tests: adds tests for DashboardQueryRunner * Refactor: adds mergePanelAndDashboardData function * Tests: fixes broken tests * Chore: Fixes errors after merge with master * Chore: Removes usage of AnnotationSrv from event_editor and initDashboard * WIP: getting annotations and alerts working in graph (snapshot not working) * Refactor: fixes snapshot data for React panels * Refactor: Fixes so snapshots work for Graph * Refactor: moves alert types to grafana-data * Refactor: changes to some for readability * Tests: skipping tests for now, needs rewrite * Refactor: refactors out common static functions to utils * Refactor: fixes resolving annotations from dataframes * Refactor: removes getRunners/Workers functions * Docs: fixes docs errors * Docs: trying to fix doc error * Refactor: changes after PR comments * Refactor: hides everything behind a factory instead * Refactor: adds cancellation between runs and explicitly --- packages/grafana-data/src/types/alerts.ts | 22 ++ packages/grafana-data/src/types/index.ts | 1 + packages/grafana-data/src/types/panel.ts | 7 + .../features/annotations/annotations_srv.ts | 11 +- public/app/features/annotations/api.ts | 14 + .../app/features/annotations/event_editor.ts | 13 +- public/app/features/annotations/types.ts | 2 +- .../dashboard/containers/DashboardPage.tsx | 5 +- .../dashboard/containers/SoloPanelPage.tsx | 4 +- .../dashboard/state/DashboardModel.ts | 2 +- .../dashboard/state/initDashboard.test.ts | 35 +- .../features/dashboard/state/initDashboard.ts | 9 +- .../dashboard/utils/loadSnapshotData.ts | 8 +- .../AlertStatesWorker.test.ts | 123 +++++++ .../DashboardQueryRunner/AlertStatesWorker.ts | 41 +++ .../AnnotationsQueryRunner.test.ts | 127 ++++++++ .../AnnotationsQueryRunner.ts | 29 ++ .../AnnotationsWorker.test.ts | 197 ++++++++++++ .../DashboardQueryRunner/AnnotationsWorker.ts | 78 +++++ .../DashboardQueryRunner.test.ts | 301 ++++++++++++++++++ .../DashboardQueryRunner.ts | 143 +++++++++ .../LegacyAnnotationQueryRunner.test.ts | 113 +++++++ .../LegacyAnnotationQueryRunner.ts | 22 ++ .../SnapshotWorker.test.ts | 105 ++++++ .../DashboardQueryRunner/SnapshotWorker.ts | 37 +++ .../state/DashboardQueryRunner/testHelpers.ts | 56 ++++ .../query/state/DashboardQueryRunner/types.ts | 41 +++ .../query/state/DashboardQueryRunner/utils.ts | 81 +++++ .../query/state/PanelQueryRunner.test.ts | 28 +- .../features/query/state/PanelQueryRunner.ts | 10 +- .../query/state/mergePanelAndDashData.test.ts | 120 +++++++ .../query/state/mergePanelAndDashData.ts | 34 ++ public/app/plugins/panel/graph/module.ts | 69 ++-- .../plugins/panel/graph/specs/graph.test.ts | 3 +- .../panel/graph/specs/graph_ctrl.test.ts | 8 +- 35 files changed, 1781 insertions(+), 118 deletions(-) create mode 100644 packages/grafana-data/src/types/alerts.ts create mode 100644 public/app/features/annotations/api.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/testHelpers.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/types.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/utils.ts create mode 100644 public/app/features/query/state/mergePanelAndDashData.test.ts create mode 100644 public/app/features/query/state/mergePanelAndDashData.ts diff --git a/packages/grafana-data/src/types/alerts.ts b/packages/grafana-data/src/types/alerts.ts new file mode 100644 index 00000000000..895ff324a7d --- /dev/null +++ b/packages/grafana-data/src/types/alerts.ts @@ -0,0 +1,22 @@ +/** + * @internal -- might be replaced by next generation Alerting + */ +export enum AlertState { + NoData = 'no_data', + Paused = 'paused', + Alerting = 'alerting', + OK = 'ok', + Pending = 'pending', + Unknown = 'unknown', +} + +/** + * @internal -- might be replaced by next generation Alerting + */ +export interface AlertStateInfo { + id: number; + dashboardId: number; + panelId: number; + state: AlertState; + newStateDate: string; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index d7d22d6308f..3fea83f134e 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -32,3 +32,4 @@ export * from './variables'; export * from './geometry'; export { isUnsignedPluginSignature } from './pluginSignature'; export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config'; +export * from './alerts'; diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index b23fd46e159..c0904cf8781 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -10,6 +10,7 @@ import { Registry } from '../utils'; import { StandardEditorProps } from '../field'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; import { OptionEditorConfig } from './options'; +import { AlertStateInfo } from './alerts'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -38,6 +39,12 @@ export interface PanelData { /** A list of annotation items */ annotations?: DataFrame[]; + /** + * @internal + * @deprecated alertState is deprecated and will be removed when the next generation Alerting is in place + */ + alertState?: AlertStateInfo; + /** Request contains the queries and properties sent to the datasource */ request?: DataQueryRequest; diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index 77b49846eb3..a10d1b36674 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -1,5 +1,5 @@ // Libaries -import { flattenDeep, cloneDeep } from 'lodash'; +import { cloneDeep, flattenDeep } from 'lodash'; // Components import coreModule from 'app/core/core_module'; // Utils & Services @@ -24,6 +24,7 @@ import { AnnotationQueryOptions, AnnotationQueryResponse } from './types'; import { standardAnnotationSupport } from './standardAnnotationSupport'; import { runRequest } from '../query/state/runRequest'; import { RefreshEvent } from 'app/types/events'; +import { deleteAnnotation, saveAnnotation, updateAnnotation } from './api'; let counter = 100; function getNextRequestId() { @@ -176,19 +177,17 @@ export class AnnotationsSrv { saveAnnotationEvent(annotation: AnnotationEvent) { this.globalAnnotationsPromise = null; - return getBackendSrv().post('/api/annotations', annotation); + return saveAnnotation(annotation); } updateAnnotationEvent(annotation: AnnotationEvent) { this.globalAnnotationsPromise = null; - return getBackendSrv().put(`/api/annotations/${annotation.id}`, annotation); + return updateAnnotation(annotation); } deleteAnnotationEvent(annotation: AnnotationEvent) { this.globalAnnotationsPromise = null; - const deleteUrl = `/api/annotations/${annotation.id}`; - - return getBackendSrv().delete(deleteUrl); + return deleteAnnotation(annotation); } translateQueryResult(annotation: any, results: any) { diff --git a/public/app/features/annotations/api.ts b/public/app/features/annotations/api.ts new file mode 100644 index 00000000000..113ebbaa733 --- /dev/null +++ b/public/app/features/annotations/api.ts @@ -0,0 +1,14 @@ +import { AnnotationEvent } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; + +export function saveAnnotation(annotation: AnnotationEvent) { + return getBackendSrv().post('/api/annotations', annotation); +} + +export function updateAnnotation(annotation: AnnotationEvent) { + return getBackendSrv().put(`/api/annotations/${annotation.id}`, annotation); +} + +export function deleteAnnotation(annotation: AnnotationEvent) { + return getBackendSrv().delete(`/api/annotations/${annotation.id}`); +} diff --git a/public/app/features/annotations/event_editor.ts b/public/app/features/annotations/event_editor.ts index 28a12030c6c..1b92798d5ea 100644 --- a/public/app/features/annotations/event_editor.ts +++ b/public/app/features/annotations/event_editor.ts @@ -1,8 +1,8 @@ import { cloneDeep, isNumber } from 'lodash'; import { coreModule } from 'app/core/core'; import { AnnotationEvent, dateTime } from '@grafana/data'; -import { AnnotationsSrv } from './all'; import { MetricsPanelCtrl } from '../panel/metrics_panel_ctrl'; +import { deleteAnnotation, saveAnnotation, updateAnnotation } from './api'; export class EventEditorCtrl { // @ts-ignore initialized through Angular not constructor @@ -15,7 +15,7 @@ export class EventEditorCtrl { timeFormated?: string; /** @ngInject */ - constructor(private annotationsSrv: AnnotationsSrv) {} + constructor() {} $onInit() { this.event.panelId = this.panelCtrl.panel.id; @@ -49,8 +49,7 @@ export class EventEditorCtrl { } if (saveModel.id) { - this.annotationsSrv - .updateAnnotationEvent(saveModel) + updateAnnotation(saveModel) .then(() => { this.panelCtrl.refresh(); this.close(); @@ -60,8 +59,7 @@ export class EventEditorCtrl { this.close(); }); } else { - this.annotationsSrv - .saveAnnotationEvent(saveModel) + saveAnnotation(saveModel) .then(() => { this.panelCtrl.refresh(); this.close(); @@ -74,8 +72,7 @@ export class EventEditorCtrl { } delete() { - return this.annotationsSrv - .deleteAnnotationEvent(this.event) + return deleteAnnotation(this.event) .then(() => { this.panelCtrl.refresh(); this.close(); diff --git a/public/app/features/annotations/types.ts b/public/app/features/annotations/types.ts index 8eae21c2816..eaf0e43a859 100644 --- a/public/app/features/annotations/types.ts +++ b/public/app/features/annotations/types.ts @@ -1,4 +1,4 @@ -import { PanelData, AnnotationEvent, TimeRange } from '@grafana/data'; +import { AnnotationEvent, PanelData, TimeRange } from '@grafana/data'; import { DashboardModel, PanelModel } from '../dashboard/state'; export interface AnnotationQueryOptions { diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 53b4ecf1304..2e848352455 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -3,9 +3,9 @@ import React, { MouseEvent, PureComponent } from 'react'; import { css } from 'emotion'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { getLegacyAngularInjector, locationService } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { selectors } from '@grafana/e2e-selectors'; -import { CustomScrollbar, stylesFactory, withTheme, Themeable } from '@grafana/ui'; +import { CustomScrollbar, stylesFactory, Themeable, withTheme } from '@grafana/ui'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { Branding } from 'app/core/components/Branding/Branding'; @@ -110,7 +110,6 @@ export class UnthemedDashboardPage extends PureComponent { } this.props.initDashboard({ - $injector: getLegacyAngularInjector(), urlSlug: match.params.slug, urlUid: match.params.uid, urlType: match.params.type, diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index d450ffe7870..78046f961f1 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -8,9 +8,8 @@ import { DashboardPanel } from '../dashgrid/DashboardPanel'; import { initDashboard } from '../state/initDashboard'; // Types import { StoreState } from 'app/types'; -import { PanelModel, DashboardModel } from 'app/features/dashboard/state'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { getLegacyAngularInjector } from '@grafana/runtime'; export interface DashboardPageRouteParams { uid?: string; @@ -38,7 +37,6 @@ export class SoloPanelPage extends Component { const { match, route } = this.props; this.props.initDashboard({ - $injector: getLegacyAngularInjector(), urlSlug: match.params.slug, urlUid: match.params.uid, urlType: match.params.type, diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index b16bf2bb03f..349a5ba4970 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -1,6 +1,7 @@ // Libaries import { cloneDeep, + defaults as _defaults, each, filter, find, @@ -11,7 +12,6 @@ import { maxBy, pull, some, - defaults as _defaults, } from 'lodash'; // Constants import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index 0a31428ddf1..112af5d436a 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -14,6 +14,11 @@ import { keybindingSrv } from 'app/core/services/keybindingSrv'; import { getTimeSrv, setTimeSrv } from '../services/TimeSrv'; import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv'; import { getDashboardSrv, setDashboardSrv } from '../services/DashboardSrv'; +import { + getDashboardQueryRunner, + setDashboardQueryRunnerFactory, +} from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { emptyResult } from '../../query/state/DashboardQueryRunner/utils'; jest.mock('app/core/services/backend_srv'); jest.mock('app/features/dashboard/services/TimeSrv', () => { @@ -38,7 +43,6 @@ const mockStore = configureMockStore([thunk]); interface ScenarioContext { args: InitDashboardArgs; - annotationsSrv: any; loaderSrv: any; backendSrv: any; setup: (fn: () => void) => void; @@ -50,8 +54,6 @@ type ScenarioFn = (ctx: ScenarioContext) => void; function describeInitScenario(description: string, scenarioFn: ScenarioFn) { describe(description, () => { - const annotationsSrv = { init: jest.fn() }; - const loaderSrv = { loadDashboard: jest.fn(() => ({ meta: { @@ -84,29 +86,22 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { }; setDashboardLoaderSrv((loaderSrv as unknown) as DashboardLoaderSrv); - - const injectorMock = { - get: (name: string) => { - switch (name) { - case 'annotationsSrv': - return annotationsSrv; - default: - throw { message: 'Unknown service ' + name }; - } - }, - }; + setDashboardQueryRunnerFactory(() => ({ + getResult: emptyResult, + run: jest.fn(), + cancel: () => undefined, + destroy: () => undefined, + })); let setupFn = () => {}; const ctx: ScenarioContext = { args: { urlUid: 'DGmvKKxZz', - $injector: injectorMock, fixUrl: false, routeName: DashboardRoutes.Normal, }, backendSrv: getBackendSrv(), - annotationsSrv, loaderSrv, actions: [], storeState: { @@ -185,7 +180,7 @@ describeInitScenario('Initializing new dashboard', (ctx) => { it('Should initialize services', () => { expect(getTimeSrv().init).toBeCalled(); expect(getDashboardSrv().setCurrent).toBeCalled(); - expect(ctx.annotationsSrv.init).toBeCalled(); + expect(getDashboardQueryRunner().run).toBeCalled(); expect(keybindingSrv.setupDashboardBindings).toBeCalled(); }); }); @@ -256,8 +251,8 @@ describeInitScenario('Initializing existing dashboard', (ctx) => { it('Should initialize services', () => { expect(getTimeSrv().init).toBeCalled(); - expect(ctx.annotationsSrv.init).toBeCalled(); expect(getDashboardSrv().setCurrent).toBeCalled(); + expect(getDashboardQueryRunner().run).toBeCalled(); expect(keybindingSrv.setupDashboardBindings).toBeCalled(); }); @@ -286,9 +281,9 @@ describeInitScenario('Initializing previously canceled dashboard initialization' expect(dashboardInitCompletedAction).toBe(undefined); }); - it('Should initialize timeSrv and annotationsSrv', () => { + it('Should initialize timeSrv and dashboard query runner', () => { expect(getTimeSrv().init).toBeCalled(); - expect(ctx.annotationsSrv.init).toBeCalled(); + expect(getDashboardQueryRunner().run).toBeCalled(); }); it('Should not initialize other services', () => { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index bd57d3703ef..03419a2fa1c 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -4,7 +4,6 @@ import { backendSrv } from 'app/core/services/backend_srv'; import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; -import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; import { keybindingSrv } from 'app/core/services/keybindingSrv'; // Actions import { notifyApp } from 'app/core/actions'; @@ -17,7 +16,7 @@ import { dashboardInitSlow, } from './reducers'; // Types -import { DashboardDTO, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult, DashboardInitPhase } from 'app/types'; +import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types'; import { DashboardModel } from './DashboardModel'; import { DataQuery, locationUtil } from '@grafana/data'; import { initVariablesTransaction } from '../../variables/state/actions'; @@ -25,9 +24,9 @@ import { emitDashboardViewEvent } from './analyticsProcessor'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { locationService } from '@grafana/runtime'; import { ChangeTracker } from '../services/ChangeTracker'; +import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; export interface InitDashboardArgs { - $injector: any; urlUid?: string; urlSlug?: string; urlType?: string; @@ -174,12 +173,12 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { // init services const timeSrv: TimeSrv = getTimeSrv(); - const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv'); const dashboardSrv: DashboardSrv = getDashboardSrv(); const changeTracker = new ChangeTracker(); timeSrv.init(dashboard); - annotationsSrv.init(dashboard); + const runner = createDashboardQueryRunner({ dashboard, timeSrv }); + runner.run({ dashboard, range: timeSrv.timeRange() }); if (storeState.dashboard.modifiedQueries) { const { panelId, queries } = storeState.dashboard.modifiedQueries; diff --git a/public/app/features/dashboard/utils/loadSnapshotData.ts b/public/app/features/dashboard/utils/loadSnapshotData.ts index 33e26504fa9..d0ddf58f49d 100644 --- a/public/app/features/dashboard/utils/loadSnapshotData.ts +++ b/public/app/features/dashboard/utils/loadSnapshotData.ts @@ -1,10 +1,15 @@ -import { applyFieldOverrides, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; +import { applyFieldOverrides, ArrayDataFrame, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; import { config } from 'app/core/config'; import { DashboardModel, PanelModel } from '../state'; import { getProcessedDataFrames } from '../../query/state/runRequest'; +import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker'; export function loadSnapshotData(panel: PanelModel, dashboard: DashboardModel): PanelData { const data = getProcessedDataFrames(panel.snapshotData); + const worker = new SnapshotWorker(); + const options = { dashboard, range: getDefaultTimeRange() }; + const annotationEvents = worker.canWork(options) ? worker.getAnnotationsInSnapshot(dashboard, panel.id) : []; + const annotations = [new ArrayDataFrame(annotationEvents)]; return { timeRange: getDefaultTimeRange(), @@ -20,5 +25,6 @@ export function loadSnapshotData(panel: PanelModel, dashboard: DashboardModel): theme: config.theme, timeZone: dashboard.getTimezone(), }), + annotations, }; } diff --git a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts new file mode 100644 index 00000000000..1e4f2593631 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts @@ -0,0 +1,123 @@ +import { AlertState, AlertStateInfo, getDefaultTimeRange, TimeRange } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { DashboardQueryRunnerOptions } from './types'; +import { AlertStatesWorker } from './AlertStatesWorker'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; +import * as store from '../../../../store/store'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +function getDefaultOptions(): DashboardQueryRunnerOptions { + const dashboard: any = { id: 'an id' }; + const range = getDefaultTimeRange(); + + return { dashboard, range }; +} + +function getTestContext() { + jest.clearAllMocks(); + const dispatchMock = jest.spyOn(store, 'dispatch'); + const options = getDefaultOptions(); + const getMock = jest.spyOn(backendSrv, 'get'); + + return { getMock, options, dispatchMock }; +} + +describe('AlertStatesWorker', () => { + const worker = new AlertStatesWorker(); + + describe('when canWork is called with correct props', () => { + it('then it should return true', () => { + const options = getDefaultOptions(); + + expect(worker.canWork(options)).toBe(true); + }); + }); + + describe('when canWork is called with no dashboard id', () => { + it('then it should return false', () => { + const dashboard: any = {}; + const options = { ...getDefaultOptions(), dashboard }; + + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when canWork is called with wrong range', () => { + it('then it should return false', () => { + const defaultRange = getDefaultTimeRange(); + const range: TimeRange = { ...defaultRange, raw: { ...defaultRange.raw, to: 'now-6h' } }; + const options = { ...getDefaultOptions(), range }; + + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when run is called with incorrect props', () => { + it('then it should return the correct results', async () => { + const { getMock, options } = getTestContext(); + const dashboard: any = {}; + + await expect(worker.work({ ...options, dashboard })).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: [], annotations: [] }); + expect(getMock).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when run is called with correct props and request is successful', () => { + it('then it should return the correct results', async () => { + const getResults: AlertStateInfo[] = [ + { id: 1, state: AlertState.Alerting, newStateDate: '2021-01-01', dashboardId: 1, panelId: 1 }, + { id: 2, state: AlertState.Alerting, newStateDate: '2021-02-01', dashboardId: 1, panelId: 2 }, + ]; + const { getMock, options } = getTestContext(); + getMock.mockResolvedValue(getResults); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: getResults, annotations: [] }); + expect(getMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called with correct props and request fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { getMock, options, dispatchMock } = getTestContext(); + getMock.mockRejectedValue({ message: 'An error' }); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: [], annotations: [] }); + expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called with correct props and request is cancelled', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { getMock, options, dispatchMock } = getTestContext(); + getMock.mockRejectedValue({ cancelled: true }); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: [], annotations: [] }); + expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts new file mode 100644 index 00000000000..cc7c269644b --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts @@ -0,0 +1,41 @@ +import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types'; +import { from, Observable } from 'rxjs'; +import { getBackendSrv } from '@grafana/runtime'; +import { catchError, map } from 'rxjs/operators'; +import { emptyResult, handleDashboardQueryRunnerWorkerError } from './utils'; + +export class AlertStatesWorker implements DashboardQueryRunnerWorker { + canWork({ dashboard, range }: DashboardQueryRunnerOptions): boolean { + if (!dashboard.id) { + return false; + } + + if (range.raw.to !== 'now') { + return false; + } + + return true; + } + + work(options: DashboardQueryRunnerOptions): Observable { + if (!this.canWork(options)) { + return emptyResult(); + } + + const { dashboard } = options; + return from( + getBackendSrv().get( + '/api/alerts/states-for-dashboard', + { + dashboardId: dashboard.id, + }, + `dashboard-query-runner-alert-states-${dashboard.id}` + ) + ).pipe( + map((alertStates) => { + return { alertStates, annotations: [] }; + }), + catchError(handleDashboardQueryRunnerWorkerError) + ); + } +} diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts new file mode 100644 index 00000000000..5b0740689db --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts @@ -0,0 +1,127 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { AnnotationsQueryRunner } from './AnnotationsQueryRunner'; +import { AnnotationQueryRunnerOptions } from './types'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; +import * as store from '../../../../store/store'; +import * as annotationsSrv from '../../../annotations/annotations_srv'; +import { Observable, of, throwError } from 'rxjs'; +import { toAsyncOfResult } from './testHelpers'; + +function getDefaultOptions(): AnnotationQueryRunnerOptions { + const annotation: any = {}; + const dashboard: any = {}; + const datasource: any = { + annotationQuery: {}, + annotations: {}, + }; + const range = getDefaultTimeRange(); + + return { annotation, datasource, dashboard, range }; +} + +function getTestContext(result: Observable = toAsyncOfResult({ events: [{ id: '1' }] })) { + jest.clearAllMocks(); + const dispatchMock = jest.spyOn(store, 'dispatch'); + const options = getDefaultOptions(); + const executeAnnotationQueryMock = jest.spyOn(annotationsSrv, 'executeAnnotationQuery').mockReturnValue(result); + + return { options, dispatchMock, executeAnnotationQueryMock }; +} + +describe('AnnotationsQueryRunner', () => { + const runner = new AnnotationsQueryRunner(); + + describe('when canWork is called with correct props', () => { + it('then it should return true', () => { + const datasource: any = { + annotationQuery: jest.fn(), + annotations: {}, + }; + + expect(runner.canRun(datasource)).toBe(true); + }); + }); + + describe('when canWork is called with incorrect props', () => { + it('then it should return false', () => { + const datasource: any = { + annotationQuery: jest.fn(), + }; + + expect(runner.canRun(datasource)).toBe(false); + }); + }); + + describe('when run is called with unsupported props', () => { + it('then it should return the correct results', async () => { + const datasource: any = { + annotationQuery: jest.fn(), + }; + const { options, executeAnnotationQueryMock } = getTestContext(); + + await expect(runner.run({ ...options, datasource })).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('when run is called and the request is successful', () => { + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock } = getTestContext(); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([{ id: '1' }]); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('but result is missing events prop', () => { + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock } = getTestContext(of({ id: '1' })); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + + describe('when run is called and the request fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock, dispatchMock } = getTestContext(throwError({ message: 'An error' })); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called and the request is cancelled', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock, dispatchMock } = getTestContext(throwError({ cancelled: true })); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.ts new file mode 100644 index 00000000000..859f99e172b --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.ts @@ -0,0 +1,29 @@ +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { AnnotationEvent, DataSourceApi } from '@grafana/data'; + +import { AnnotationQueryRunner, AnnotationQueryRunnerOptions } from './types'; +import { PanelModel } from '../../../dashboard/state'; +import { executeAnnotationQuery } from '../../../annotations/annotations_srv'; +import { handleAnnotationQueryRunnerError } from './utils'; + +export class AnnotationsQueryRunner implements AnnotationQueryRunner { + canRun(datasource: DataSourceApi): boolean { + return !Boolean(datasource.annotationQuery && !datasource.annotations); + } + + run({ annotation, datasource, dashboard, range }: AnnotationQueryRunnerOptions): Observable { + if (!this.canRun(datasource)) { + return of([]); + } + + const panel: PanelModel = ({} as unknown) as PanelModel; // deliberate setting panel to empty object because executeAnnotationQuery shouldn't depend on panelModel + + return executeAnnotationQuery({ dashboard, range, panel }, datasource, annotation).pipe( + map((result) => { + return result.events ?? []; + }), + catchError(handleAnnotationQueryRunnerError) + ); + } +} diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts new file mode 100644 index 00000000000..1ac864d5841 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts @@ -0,0 +1,197 @@ +import { throwError } from 'rxjs'; +import { setDataSourceSrv } from '@grafana/runtime'; + +import { AnnotationsWorker } from './AnnotationsWorker'; +import * as annotationsSrv from '../../../annotations/annotations_srv'; +import { getDefaultOptions, LEGACY_DS_NAME, NEXT_GEN_DS_NAME, toAsyncOfResult } from './testHelpers'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; + +function getTestContext() { + jest.clearAllMocks(); + const executeAnnotationQueryMock = jest + .spyOn(annotationsSrv, 'executeAnnotationQuery') + .mockReturnValue(toAsyncOfResult({ events: [{ id: 'NextGen' }] })); + const annotationQueryMock = jest.fn().mockResolvedValue([{ id: 'Legacy' }]); + const dataSourceSrvMock: any = { + get: async (name: string) => { + if (name === LEGACY_DS_NAME) { + return { + annotationQuery: annotationQueryMock, + }; + } + + if (name === NEXT_GEN_DS_NAME) { + return { + annotations: {}, + }; + } + + return {}; + }, + }; + setDataSourceSrv(dataSourceSrvMock); + const options = getDefaultOptions(); + + return { options, annotationQueryMock, executeAnnotationQueryMock }; +} + +describe('AnnotationsWorker', () => { + const worker = new AnnotationsWorker(); + + describe('when canWork is called with correct props', () => { + it('then it should return true', () => { + const options = getDefaultOptions(); + + expect(worker.canWork(options)).toBe(true); + }); + }); + + describe('when canWork is called with incorrect props', () => { + it('then it should return false', () => { + const dashboard: any = { annotations: { list: [] } }; + const options = { ...getDefaultOptions(), dashboard }; + + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when run is called with incorrect props', () => { + it('then it should return the correct results', async () => { + const dashboard: any = { annotations: { list: [] } }; + const options = { ...getDefaultOptions(), dashboard }; + + await expect(worker.work(options)).toEmitValues([{ alertStates: [], annotations: [] }]); + }); + }); + + describe('when run is called with correct props and all workers are successful', () => { + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock, annotationQueryMock } = getTestContext(); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const result = received[0]; + expect(result).toEqual({ + alertStates: [], + annotations: [ + { + id: 'Legacy', + source: { + enable: true, + hide: false, + name: 'Test', + iconColor: 'pink', + snapshotData: undefined, + datasource: 'Legacy', + }, + color: 'pink', + type: 'Test', + isRegion: false, + }, + { + id: 'NextGen', + source: { + enable: true, + hide: false, + name: 'Test', + iconColor: 'pink', + snapshotData: undefined, + datasource: 'NextGen', + }, + color: 'pink', + type: 'Test', + isRegion: false, + }, + ], + }); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called with correct props and legacy worker fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock, annotationQueryMock } = getTestContext(); + annotationQueryMock.mockRejectedValue({ message: 'Some error' }); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const result = received[0]; + expect(result).toEqual({ + alertStates: [], + annotations: [ + { + id: 'NextGen', + source: { + enable: true, + hide: false, + name: 'Test', + iconColor: 'pink', + snapshotData: undefined, + datasource: 'NextGen', + }, + color: 'pink', + type: 'Test', + isRegion: false, + }, + ], + }); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('when run is called with correct props and nextgen worker fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock, annotationQueryMock } = getTestContext(); + executeAnnotationQueryMock.mockReturnValue(throwError({ message: 'An error' })); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const result = received[0]; + expect(result).toEqual({ + alertStates: [], + annotations: [ + { + id: 'Legacy', + source: { + enable: true, + hide: false, + name: 'Test', + iconColor: 'pink', + snapshotData: undefined, + datasource: 'Legacy', + }, + color: 'pink', + type: 'Test', + isRegion: false, + }, + ], + }); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called with correct props and both workers fail', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { options, executeAnnotationQueryMock, annotationQueryMock } = getTestContext(); + annotationQueryMock.mockRejectedValue({ message: 'Some error' }); + executeAnnotationQueryMock.mockReturnValue(throwError({ message: 'An error' })); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const result = received[0]; + expect(result).toEqual({ alertStates: [], annotations: [] }); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.ts new file mode 100644 index 00000000000..54f556a1e77 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.ts @@ -0,0 +1,78 @@ +import { cloneDeep } from 'lodash'; +import { from, merge, Observable, of } from 'rxjs'; +import { map, mergeAll, mergeMap, reduce } from 'rxjs/operators'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { AnnotationQuery, DataSourceApi } from '@grafana/data'; + +import { + AnnotationQueryRunner, + DashboardQueryRunnerOptions, + DashboardQueryRunnerWorker, + DashboardQueryRunnerWorkerResult, +} from './types'; +import { emptyResult, translateQueryResult } from './utils'; +import { LegacyAnnotationQueryRunner } from './LegacyAnnotationQueryRunner'; +import { AnnotationsQueryRunner } from './AnnotationsQueryRunner'; + +export class AnnotationsWorker implements DashboardQueryRunnerWorker { + constructor( + private readonly runners: AnnotationQueryRunner[] = [ + new LegacyAnnotationQueryRunner(), + new AnnotationsQueryRunner(), + ] + ) {} + + canWork({ dashboard }: DashboardQueryRunnerOptions): boolean { + const annotations = dashboard.annotations.list.find(AnnotationsWorker.getAnnotationsToProcessFilter); + return Boolean(annotations); + } + + work(options: DashboardQueryRunnerOptions): Observable { + if (!this.canWork(options)) { + return emptyResult(); + } + + const { dashboard, range } = options; + const annotations = dashboard.annotations.list.filter(AnnotationsWorker.getAnnotationsToProcessFilter); + const observables = annotations.map((annotation) => { + const datasourcePromise = getDataSourceSrv().get(annotation.datasource); + return from(datasourcePromise).pipe( + mergeMap((datasource: DataSourceApi) => { + const runner = this.runners.find((r) => r.canRun(datasource)); + if (!runner) { + return of([]); + } + + return runner.run({ annotation, datasource, dashboard, range }).pipe( + map((results) => { + // store response in annotation object if this is a snapshot call + if (dashboard.snapshot) { + annotation.snapshotData = cloneDeep(results); + } + // translate result + return translateQueryResult(annotation, results); + }) + ); + }) + ); + }); + + return merge(observables).pipe( + mergeAll(), + reduce((acc, value) => { + // should we use scan or reduce here + // reduce will only emit when all observables are completed + // scan will emit when any observable is completed + // choosing reduce to minimize re-renders + return acc.concat(value); + }), + map((annotations) => { + return { annotations, alertStates: [] }; + }) + ); + } + + private static getAnnotationsToProcessFilter(annotation: AnnotationQuery): boolean { + return annotation.enable && !Boolean(annotation.snapshotData); + } +} diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts new file mode 100644 index 00000000000..a35ca2d1c45 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -0,0 +1,301 @@ +import { throwError } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { setDataSourceSrv } from '@grafana/runtime'; +import { AlertState, AlertStateInfo } from '@grafana/data'; + +import * as annotationsSrv from '../../../annotations/annotations_srv'; +import { getDefaultOptions, LEGACY_DS_NAME, NEXT_GEN_DS_NAME, toAsyncOfResult } from './testHelpers'; +import { backendSrv } from '../../../../core/services/backend_srv'; +import { DashboardQueryRunner, DashboardQueryRunnerResult } from './types'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; +import { createDashboardQueryRunner } from './DashboardQueryRunner'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +function getTestContext() { + jest.clearAllMocks(); + const timeSrvMock: any = { timeRange: jest.fn() }; + const options = getDefaultOptions(); + // These tests are setup so all the workers and runners are invoked once, this wouldn't be the case in real life + const runner = createDashboardQueryRunner({ dashboard: options.dashboard, timeSrv: timeSrvMock }); + + const getResults: AlertStateInfo[] = [ + { id: 1, state: AlertState.Alerting, newStateDate: '2021-01-01', dashboardId: 1, panelId: 1 }, + { id: 2, state: AlertState.Alerting, newStateDate: '2021-02-01', dashboardId: 1, panelId: 2 }, + ]; + const getMock = jest.spyOn(backendSrv, 'get').mockResolvedValue(getResults); + const executeAnnotationQueryMock = jest + .spyOn(annotationsSrv, 'executeAnnotationQuery') + .mockReturnValue(toAsyncOfResult({ events: [{ id: 'NextGen' }] })); + const annotationQueryMock = jest.fn().mockResolvedValue([{ id: 'Legacy' }]); + const dataSourceSrvMock: any = { + get: async (name: string) => { + if (name === LEGACY_DS_NAME) { + return { + annotationQuery: annotationQueryMock, + }; + } + + if (name === NEXT_GEN_DS_NAME) { + return { + annotations: {}, + }; + } + + return {}; + }, + }; + setDataSourceSrv(dataSourceSrvMock); + + return { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock }; +} + +function expectOnResults(args: { + runner: DashboardQueryRunner; + panelId: number; + done: jest.DoneCallback; + expect: (results: DashboardQueryRunnerResult) => void; +}) { + const { runner, done, panelId, expect: expectCallback } = args; + const subscription = runner.getResult(panelId).subscribe({ + next: (value) => { + try { + expectCallback(value); + subscription.unsubscribe(); + done(); + } catch (err) { + subscription.unsubscribe(); + done.fail(err); + } + }, + }); +} + +describe('DashboardQueryRunnerImpl', () => { + describe('when calling run and all workers succeed', () => { + it('then it should return the correct results', (done) => { + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + + expectOnResults({ + runner, + panelId: 1, + done, + expect: (results) => { + // should have one alert state, one snapshot, one legacy and one next gen result + // having both snapshot and legacy/next gen is a imaginary example for testing purposes and doesn't exist for real + expect(results).toEqual(getExpectedForAllResult()); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledTimes(1); + }, + }); + + runner.run(options); + }); + }); + + describe('when calling run and all workers fail', () => { + silenceConsoleOutput(); + it('then it should return the correct results', (done) => { + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + getMock.mockRejectedValue({ message: 'Get error' }); + annotationQueryMock.mockRejectedValue({ message: 'Legacy error' }); + executeAnnotationQueryMock.mockReturnValue(throwError({ message: 'NextGen error' })); + + expectOnResults({ + runner, + panelId: 1, + done, + expect: (results) => { + // should have one alert state, one snapshot, one legacy and one next gen result + // having both snapshot and legacy/next gen is a imaginary example for testing purposes and doesn't exist for real + const expected = { alertState: undefined, annotations: [getExpectedForAllResult().annotations[2]] }; + expect(results).toEqual(expected); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledTimes(1); + }, + }); + + runner.run(options); + }); + }); + + describe('when calling run and AlertStatesWorker fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', (done) => { + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + getMock.mockRejectedValue({ message: 'Get error' }); + + expectOnResults({ + runner, + panelId: 1, + done, + expect: (results) => { + // should have one alert state, one snapshot, one legacy and one next gen result + // having both snapshot and legacy/next gen is a imaginary example for testing purposes and doesn't exist for real + const { annotations } = getExpectedForAllResult(); + const expected = { alertState: undefined, annotations }; + expect(results).toEqual(expected); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledTimes(1); + }, + }); + + runner.run(options); + }); + + describe('when calling run and AnnotationsWorker fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', (done) => { + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + annotationQueryMock.mockRejectedValue({ message: 'Legacy error' }); + executeAnnotationQueryMock.mockReturnValue(throwError({ message: 'NextGen error' })); + + expectOnResults({ + runner, + panelId: 1, + done, + expect: (results) => { + // should have one alert state, one snapshot, one legacy and one next gen result + // having both snapshot and legacy/next gen is a imaginary example for testing purposes and doesn't exist for real + const { alertState, annotations } = getExpectedForAllResult(); + const expected = { alertState, annotations: [annotations[2]] }; + expect(results).toEqual(expected); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledTimes(1); + }, + }); + + runner.run(options); + }); + }); + }); + + describe('when calling run twice', () => { + it('then it should cancel previous run', (done) => { + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + executeAnnotationQueryMock.mockReturnValueOnce( + toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(10000)) + ); + + expectOnResults({ + runner, + panelId: 1, + done, + expect: (results) => { + // should have one alert state, one snapshot, one legacy and one next gen result + // having both snapshot and legacy/next gen is a imaginary example for testing purposes and doesn't exist for real + const { alertState, annotations } = getExpectedForAllResult(); + const expected = { alertState, annotations }; + expect(results).toEqual(expected); + expect(annotationQueryMock).toHaveBeenCalledTimes(2); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(2); + expect(getMock).toHaveBeenCalledTimes(2); + }, + }); + + runner.run(options); + runner.run(options); + }); + }); + + describe('when calling cancel', () => { + it('then it should cancel previous run', (done) => { + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + executeAnnotationQueryMock.mockReturnValueOnce( + toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(10000)) + ); + + expectOnResults({ + runner, + panelId: 1, + done, + expect: (results) => { + // should have one alert state, one snapshot, one legacy and one next gen result + // having both snapshot and legacy/next gen is a imaginary example for testing purposes and doesn't exist for real + const expected = { alertState: undefined, annotations: [] }; + expect(results).toEqual(expected); + expect(annotationQueryMock).toHaveBeenCalledTimes(0); + expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(0); + expect(getMock).toHaveBeenCalledTimes(1); + }, + }); + + runner.run(options); + runner.cancel(); + }); + }); +}); + +function getExpectedForAllResult(): DashboardQueryRunnerResult { + return { + alertState: { + dashboardId: 1, + id: 1, + newStateDate: '2021-01-01', + panelId: 1, + state: AlertState.Alerting, + }, + annotations: [ + { + color: 'pink', + id: 'Legacy', + isRegion: false, + source: { + datasource: 'Legacy', + enable: true, + hide: false, + iconColor: 'pink', + id: undefined, + name: 'Test', + snapshotData: undefined, + }, + type: 'Test', + }, + { + color: 'pink', + id: 'NextGen', + isRegion: false, + source: { + datasource: 'NextGen', + enable: true, + hide: false, + iconColor: 'pink', + id: undefined, + name: 'Test', + snapshotData: undefined, + }, + type: 'Test', + }, + { + annotation: { + datasource: 'Legacy', + enable: true, + hide: false, + iconColor: 'pink', + id: 'Snapshotted', + name: 'Test', + }, + color: 'pink', + isRegion: true, + source: { + datasource: 'Legacy', + enable: true, + hide: false, + iconColor: 'pink', + id: 'Snapshotted', + name: 'Test', + }, + time: 1, + timeEnd: 2, + type: 'Test', + }, + ], + }; +} diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts new file mode 100644 index 00000000000..71bda6a8d64 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts @@ -0,0 +1,143 @@ +import { merge, Observable, race, Subject, Unsubscribable } from 'rxjs'; +import { map, mergeAll, reduce, share, takeUntil } from 'rxjs/operators'; + +import { dedupAnnotations } from 'app/features/annotations/events_processing'; +import { + DashboardQueryRunner, + DashboardQueryRunnerOptions, + DashboardQueryRunnerResult, + DashboardQueryRunnerWorker, + DashboardQueryRunnerWorkerResult, +} from './types'; +import { AlertStatesWorker } from './AlertStatesWorker'; +import { SnapshotWorker } from './SnapshotWorker'; +import { AnnotationsWorker } from './AnnotationsWorker'; +import { getAnnotationsByPanelId } from './utils'; +import { DashboardModel } from '../../../dashboard/state'; +import { getTimeSrv, TimeSrv } from '../../../dashboard/services/TimeSrv'; +import { RefreshEvent } from '../../../../types/events'; + +class DashboardQueryRunnerImpl implements DashboardQueryRunner { + private readonly results: Subject; + private readonly runs: Subject; + private readonly cancellations: Subject; + private readonly runsSubscription: Unsubscribable; + private readonly eventsSubscription: Unsubscribable; + + constructor( + private readonly dashboard: DashboardModel, + private readonly timeSrv: TimeSrv = getTimeSrv(), + private readonly workers: DashboardQueryRunnerWorker[] = [ + new AlertStatesWorker(), + new SnapshotWorker(), + new AnnotationsWorker(), + ] + ) { + this.run = this.run.bind(this); + this.getResult = this.getResult.bind(this); + this.cancel = this.cancel.bind(this); + this.destroy = this.destroy.bind(this); + this.executeRun = this.executeRun.bind(this); + this.results = new Subject(); + this.runs = new Subject(); + this.cancellations = new Subject(); + this.runsSubscription = this.runs.subscribe((options) => this.executeRun(options)); + this.eventsSubscription = dashboard.events.subscribe(RefreshEvent, (event) => { + this.run({ dashboard: this.dashboard, range: this.timeSrv.timeRange() }); + }); + } + + run(options: DashboardQueryRunnerOptions): void { + this.runs.next(options); + } + + getResult(panelId?: number): Observable { + return this.results.asObservable().pipe( + map((result) => { + const annotations = getAnnotationsByPanelId(result.annotations, panelId); + + const alertState = result.alertStates.find((res) => Boolean(panelId) && res.panelId === panelId); + + return { annotations: dedupAnnotations(annotations), alertState }; + }), + share() // sharing this so we can merge this with it self in mergePanelAndDashData + ); + } + + private executeRun(options: DashboardQueryRunnerOptions) { + const workers = this.workers.filter((w) => w.canWork(options)); + const observables = workers.map((w) => w.work(options)); + + merge(observables) + .pipe( + takeUntil(race(this.runs.asObservable(), this.cancellations.asObservable())), + mergeAll(), + reduce((acc, value) => { + // should we use scan or reduce here + // reduce will only emit when all observables are completed + // scan will emit when any observable is completed + // choosing reduce to minimize re-renders + acc.annotations = acc.annotations.concat(value.annotations); + acc.alertStates = acc.alertStates.concat(value.alertStates); + return acc; + }) + ) + .subscribe((x) => { + this.results.next(x); + }); + } + + cancel(): void { + this.cancellations.next(1); + this.results.next({ annotations: [], alertStates: [] }); + } + + destroy(): void { + this.results.complete(); + this.runs.complete(); + this.cancellations.complete(); + this.runsSubscription.unsubscribe(); + this.eventsSubscription.unsubscribe(); + } +} + +let dashboardQueryRunner: DashboardQueryRunner | undefined; + +function setDashboardQueryRunner(runner: DashboardQueryRunner): void { + if (dashboardQueryRunner) { + dashboardQueryRunner.destroy(); + } + dashboardQueryRunner = runner; +} + +export function getDashboardQueryRunner(): DashboardQueryRunner { + if (!dashboardQueryRunner) { + throw new Error('getDashboardQueryRunner can only be used after Grafana instance has started.'); + } + return dashboardQueryRunner; +} + +export interface DashboardQueryRunnerFactoryArgs { + dashboard: DashboardModel; + timeSrv?: TimeSrv; + workers?: DashboardQueryRunnerWorker[]; +} + +export type DashboardQueryRunnerFactory = (args: DashboardQueryRunnerFactoryArgs) => DashboardQueryRunner; + +let factory: DashboardQueryRunnerFactory | undefined; + +export function setDashboardQueryRunnerFactory(instance: DashboardQueryRunnerFactory) { + factory = instance; +} + +export function createDashboardQueryRunner(args: DashboardQueryRunnerFactoryArgs): DashboardQueryRunner { + if (!factory) { + factory = ({ dashboard, timeSrv, workers }: DashboardQueryRunnerFactoryArgs) => + new DashboardQueryRunnerImpl(dashboard, timeSrv, workers); + } + + const runner = factory(args); + setDashboardQueryRunner(runner); + return runner; +} diff --git a/public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.test.ts new file mode 100644 index 00000000000..5148c7bcbfb --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.test.ts @@ -0,0 +1,113 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { LegacyAnnotationQueryRunner } from './LegacyAnnotationQueryRunner'; +import { AnnotationQueryRunnerOptions } from './types'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; +import * as store from '../../../../store/store'; + +function getDefaultOptions(annotationQuery?: jest.Mock): AnnotationQueryRunnerOptions { + const annotation: any = {}; + const dashboard: any = {}; + const datasource: any = { + annotationQuery: annotationQuery ?? jest.fn().mockResolvedValue([{ id: '1' }]), + }; + const range = getDefaultTimeRange(); + + return { annotation, datasource, dashboard, range }; +} + +function getTestContext(annotationQuery?: jest.Mock) { + jest.clearAllMocks(); + const dispatchMock = jest.spyOn(store, 'dispatch'); + const options = getDefaultOptions(annotationQuery); + const annotationQueryMock = options.datasource.annotationQuery; + + return { options, dispatchMock, annotationQueryMock }; +} + +describe('LegacyAnnotationQueryRunner', () => { + const runner = new LegacyAnnotationQueryRunner(); + + describe('when canWork is called with correct props', () => { + it('then it should return true', () => { + const datasource: any = { + annotationQuery: jest.fn(), + }; + + expect(runner.canRun(datasource)).toBe(true); + }); + }); + + describe('when canWork is called with incorrect props', () => { + it('then it should return false', () => { + const datasource: any = { + annotationQuery: jest.fn(), + annotations: {}, + }; + + expect(runner.canRun(datasource)).toBe(false); + }); + }); + + describe('when run is called with unsupported props', () => { + it('then it should return the correct results', async () => { + const datasource: any = { + annotationQuery: jest.fn(), + annotations: {}, + }; + const options = { ...getDefaultOptions(), datasource }; + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(datasource.annotationQuery).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when run is called and the request is successful', () => { + it('then it should return the correct results', async () => { + const { options, annotationQueryMock } = getTestContext(); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([{ id: '1' }]); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called and the request fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const annotationQuery = jest.fn().mockRejectedValue({ message: 'An error' }); + const { options, annotationQueryMock, dispatchMock } = getTestContext(annotationQuery); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called and the request is cancelled', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const annotationQuery = jest.fn().mockRejectedValue({ cancelled: true }); + const { options, annotationQueryMock, dispatchMock } = getTestContext(annotationQuery); + + await expect(runner.run(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual([]); + expect(annotationQueryMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.ts b/public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.ts new file mode 100644 index 00000000000..60a8b6ebd3a --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/LegacyAnnotationQueryRunner.ts @@ -0,0 +1,22 @@ +import { from, Observable, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AnnotationEvent, DataSourceApi } from '@grafana/data'; + +import { AnnotationQueryRunner, AnnotationQueryRunnerOptions } from './types'; +import { handleAnnotationQueryRunnerError } from './utils'; + +export class LegacyAnnotationQueryRunner implements AnnotationQueryRunner { + canRun(datasource: DataSourceApi): boolean { + return Boolean(datasource.annotationQuery && !datasource.annotations); + } + + run({ annotation, datasource, dashboard, range }: AnnotationQueryRunnerOptions): Observable { + if (!this.canRun(datasource)) { + return of([]); + } + + return from(datasource.annotationQuery!({ range, rangeRaw: range.raw, annotation, dashboard })).pipe( + catchError(handleAnnotationQueryRunnerError) + ); + } +} diff --git a/public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.test.ts new file mode 100644 index 00000000000..94ad7ad2fa6 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.test.ts @@ -0,0 +1,105 @@ +import { AnnotationEvent, getDefaultTimeRange } from '@grafana/data'; + +import { DashboardQueryRunnerOptions } from './types'; +import { SnapshotWorker } from './SnapshotWorker'; + +function getDefaultOptions(): DashboardQueryRunnerOptions { + const dashboard: any = {}; + const range = getDefaultTimeRange(); + + return { dashboard, range }; +} + +function getSnapshotData(annotation: any, timeEnd: number | undefined = undefined): AnnotationEvent[] { + return [{ annotation, source: {}, timeEnd, time: 1 }]; +} + +function getAnnotation(timeEnd: number | undefined = undefined) { + const annotation = { + enable: true, + hide: false, + name: 'Test', + iconColor: 'pink', + }; + + return { + ...annotation, + snapshotData: getSnapshotData(annotation, timeEnd), + }; +} + +describe('SnapshotWorker', () => { + const worker = new SnapshotWorker(); + + describe('when canWork is called with correct props', () => { + it('then it should return true', () => { + const dashboard: any = { annotations: { list: [getAnnotation(), {}] } }; + const options = { ...getDefaultOptions(), dashboard }; + + expect(worker.canWork(options)).toBe(true); + }); + }); + + describe('when canWork is called with incorrect props', () => { + it('then it should return false', () => { + const dashboard: any = { annotations: { list: [{}] } }; + const options = { ...getDefaultOptions(), dashboard }; + + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when run is called with incorrect props', () => { + it('then it should return the correct results', async () => { + const dashboard: any = { annotations: { list: [{}] } }; + const options = { ...getDefaultOptions(), dashboard }; + + await expect(worker.work(options)).toEmitValues([{ alertStates: [], annotations: [] }]); + }); + }); + + describe('when run is called with correct props', () => { + it('then it should return the correct results', async () => { + const noRegionUndefined = getAnnotation(); + const noRegionEqualTime = getAnnotation(1); + const region = getAnnotation(2); + const noSnapshotData = { ...getAnnotation(), snapshotData: undefined }; + const dashboard: any = { annotations: { list: [noRegionUndefined, region, noSnapshotData, noRegionEqualTime] } }; + const options = { ...getDefaultOptions(), dashboard }; + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const { alertStates, annotations } = received[0]; + expect(alertStates).toBeDefined(); + expect(annotations).toHaveLength(3); + expect(annotations[0]).toEqual({ + annotation: { enable: true, hide: false, name: 'Test', iconColor: 'pink' }, + source: { enable: true, hide: false, name: 'Test', iconColor: 'pink' }, + timeEnd: undefined, + time: 1, + color: 'pink', + type: 'Test', + isRegion: false, + }); + expect(annotations[1]).toEqual({ + annotation: { enable: true, hide: false, name: 'Test', iconColor: 'pink' }, + source: { enable: true, hide: false, name: 'Test', iconColor: 'pink' }, + timeEnd: 2, + time: 1, + color: 'pink', + type: 'Test', + isRegion: true, + }); + expect(annotations[2]).toEqual({ + annotation: { enable: true, hide: false, name: 'Test', iconColor: 'pink' }, + source: { enable: true, hide: false, name: 'Test', iconColor: 'pink' }, + timeEnd: 1, + time: 1, + color: 'pink', + type: 'Test', + isRegion: false, + }); + }); + }); + }); +}); diff --git a/public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.ts b/public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.ts new file mode 100644 index 00000000000..2ed6b42cf33 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/SnapshotWorker.ts @@ -0,0 +1,37 @@ +import { Observable, of } from 'rxjs'; +import { AnnotationEvent } from '@grafana/data'; + +import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types'; +import { emptyResult, getAnnotationsByPanelId, translateQueryResult } from './utils'; +import { DashboardModel } from '../../../dashboard/state'; + +export class SnapshotWorker implements DashboardQueryRunnerWorker { + canWork({ dashboard }: DashboardQueryRunnerOptions): boolean { + return dashboard?.annotations?.list?.some((a) => a.enable && Boolean(a.snapshotData)); + } + + work(options: DashboardQueryRunnerOptions): Observable { + if (!this.canWork(options)) { + return emptyResult(); + } + + const annotations = this.getAnnotationsFromSnapshot(options.dashboard); + return of({ annotations, alertStates: [] }); + } + + private getAnnotationsFromSnapshot(dashboard: DashboardModel): AnnotationEvent[] { + const dashAnnotations = dashboard?.annotations?.list?.filter((a) => a.enable); + const snapshots = dashAnnotations.filter((a) => Boolean(a.snapshotData)); + const annotations = snapshots.reduce( + (acc, curr) => acc.concat(translateQueryResult(curr, curr.snapshotData)), + [] as AnnotationEvent[] + ); + + return annotations; + } + + getAnnotationsInSnapshot(dashboard: DashboardModel, panelId?: number): AnnotationEvent[] { + const annotations = this.getAnnotationsFromSnapshot(dashboard); + return getAnnotationsByPanelId(annotations, panelId); + } +} diff --git a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts new file mode 100644 index 00000000000..8b666c8d732 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts @@ -0,0 +1,56 @@ +import { asyncScheduler, Observable, of, scheduled, Subject } from 'rxjs'; +import { DashboardQueryRunnerOptions } from './types'; +import { AnnotationEvent, getDefaultTimeRange } from '@grafana/data'; + +// function that creates an async of result Observable +export function toAsyncOfResult(result: any): Observable { + return scheduled(of(result), asyncScheduler); +} + +export const LEGACY_DS_NAME = 'Legacy'; +export const NEXT_GEN_DS_NAME = 'NextGen'; + +function getSnapshotData(annotation: any): AnnotationEvent[] { + return [{ annotation, source: {}, timeEnd: 2, time: 1 }]; +} + +function getAnnotation({ + enable = true, + useSnapshotData = false, + datasource = LEGACY_DS_NAME, +}: { enable?: boolean; useSnapshotData?: boolean; datasource?: string } = {}) { + const annotation = { + id: useSnapshotData ? 'Snapshotted' : undefined, + enable, + hide: false, + name: 'Test', + iconColor: 'pink', + datasource, + }; + + return { + ...annotation, + snapshotData: useSnapshotData ? getSnapshotData(annotation) : undefined, + }; +} + +export function getDefaultOptions(): DashboardQueryRunnerOptions { + const legacy = getAnnotation({ datasource: LEGACY_DS_NAME }); + const nextGen = getAnnotation({ datasource: NEXT_GEN_DS_NAME }); + const dashboard: any = { + id: 1, + annotations: { + list: [ + legacy, + nextGen, + getAnnotation({ enable: false }), + getAnnotation({ useSnapshotData: true }), + getAnnotation({ enable: false, useSnapshotData: true }), + ], + }, + events: new Subject(), + }; + const range = getDefaultTimeRange(); + + return { dashboard, range }; +} diff --git a/public/app/features/query/state/DashboardQueryRunner/types.ts b/public/app/features/query/state/DashboardQueryRunner/types.ts new file mode 100644 index 00000000000..c57392539f3 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/types.ts @@ -0,0 +1,41 @@ +import { Observable } from 'rxjs'; +import { AlertStateInfo, AnnotationEvent, AnnotationQuery, DataSourceApi, TimeRange } from '@grafana/data'; + +import { DashboardModel } from '../../../dashboard/state'; + +export interface DashboardQueryRunnerOptions { + dashboard: DashboardModel; + range: TimeRange; +} + +export interface DashboardQueryRunnerResult { + annotations: AnnotationEvent[]; + alertState?: AlertStateInfo; +} + +export interface DashboardQueryRunner { + run: (options: DashboardQueryRunnerOptions) => void; + getResult: (panelId?: number) => Observable; + cancel: () => void; + destroy: () => void; +} + +export interface DashboardQueryRunnerWorkerResult { + annotations: AnnotationEvent[]; + alertStates: AlertStateInfo[]; +} + +export interface DashboardQueryRunnerWorker { + canWork: (options: DashboardQueryRunnerOptions) => boolean; + work: (options: DashboardQueryRunnerOptions) => Observable; +} + +export interface AnnotationQueryRunnerOptions extends DashboardQueryRunnerOptions { + datasource: DataSourceApi; + annotation: AnnotationQuery; +} + +export interface AnnotationQueryRunner { + canRun: (datasource: DataSourceApi) => boolean; + run: (options: AnnotationQueryRunnerOptions) => Observable; +} diff --git a/public/app/features/query/state/DashboardQueryRunner/utils.ts b/public/app/features/query/state/DashboardQueryRunner/utils.ts new file mode 100644 index 00000000000..bc350ae9383 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/utils.ts @@ -0,0 +1,81 @@ +import { cloneDeep } from 'lodash'; +import { Observable, of } from 'rxjs'; +import { AnnotationEvent, AnnotationQuery, DataFrame, DataFrameView } from '@grafana/data'; +import { toDataQueryError } from '@grafana/runtime'; + +import { dispatch } from 'app/store/store'; +import { createErrorNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; +import { DashboardQueryRunnerWorkerResult } from './types'; + +export function handleAnnotationQueryRunnerError(err: any): Observable { + if (err.cancelled) { + return of([]); + } + + notifyWithError('AnnotationQueryRunner failed', err); + return of([]); +} + +export const emptyResult: () => Observable = () => + of({ annotations: [], alertStates: [] }); + +export function handleDashboardQueryRunnerWorkerError(err: any): Observable { + if (err.cancelled) { + return emptyResult(); + } + + notifyWithError('DashboardQueryRunner failed', err); + return emptyResult(); +} + +function notifyWithError(title: string, err: any) { + const error = toDataQueryError(err); + console.error('handleAnnotationQueryRunnerError', error); + const notification = createErrorNotification(title, error.message); + dispatch(notifyApp(notification)); +} + +export function getAnnotationsByPanelId(annotations: AnnotationEvent[], panelId?: number) { + return annotations.filter((item) => { + if (panelId !== undefined && item.panelId && item.source?.type === 'dashboard') { + return item.panelId === panelId; + } + return true; + }); +} + +export function translateQueryResult(annotation: AnnotationQuery, results: AnnotationEvent[]): AnnotationEvent[] { + // if annotation has snapshotData + // make clone and remove it + if (annotation.snapshotData) { + annotation = cloneDeep(annotation); + delete annotation.snapshotData; + } + + for (const item of results) { + item.source = annotation; + item.color = annotation.iconColor; + item.type = annotation.name; + item.isRegion = Boolean(item.timeEnd && item.time !== item.timeEnd); + } + + return results; +} + +export function annotationsFromDataFrames(data?: DataFrame[]): AnnotationEvent[] { + if (!data || !data.length) { + return []; + } + + const annotations: AnnotationEvent[] = []; + for (const frame of data) { + const view = new DataFrameView(frame); + for (let index = 0; index < frame.length; index++) { + const annotation = cloneDeep(view.get(index)); + annotations.push(annotation); + } + } + + return annotations; +} diff --git a/public/app/features/query/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts index 7645dd08475..cc7cd714f34 100644 --- a/public/app/features/query/state/PanelQueryRunner.test.ts +++ b/public/app/features/query/state/PanelQueryRunner.test.ts @@ -1,4 +1,16 @@ -const applyFieldOverridesMock = jest.fn(); +const applyFieldOverridesMock = jest.fn(); // needs to be first in this file + +// Importing this way to be able to spy on grafana/data +import * as grafanaData from '@grafana/data'; +import { DashboardModel } from '../../dashboard/state/index'; +import { setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; +import { Echo } from '../../../core/services/echo/Echo'; +import { emptyResult } from './DashboardQueryRunner/utils'; +import { + createDashboardQueryRunner, + setDashboardQueryRunnerFactory, +} from './DashboardQueryRunner/DashboardQueryRunner'; +import { PanelQueryRunner } from './PanelQueryRunner'; jest.mock('@grafana/data', () => ({ __esModule: true, @@ -6,13 +18,6 @@ jest.mock('@grafana/data', () => ({ applyFieldOverrides: applyFieldOverridesMock, })); -import { PanelQueryRunner } from './PanelQueryRunner'; -// Importing this way to be able to spy on grafana/data -import * as grafanaData from '@grafana/data'; -import { DashboardModel } from '../../dashboard/state/index'; -import { setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; -import { Echo } from '../../../core/services/echo/Echo'; - jest.mock('app/core/services/backend_srv'); jest.mock('app/core/config', () => ({ config: { featureToggles: { transformations: true } }, @@ -86,6 +91,13 @@ function describeQueryRunnerScenario( }; setDataSourceSrv({} as any); + setDashboardQueryRunnerFactory(() => ({ + getResult: emptyResult, + run: () => undefined, + cancel: () => undefined, + destroy: () => undefined, + })); + createDashboardQueryRunner({} as any); beforeEach(async () => { setEchoSrv(new Echo()); diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index c15eb8f908f..5febf31f2a8 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -30,6 +30,8 @@ import { TimeZone, transformDataFrame, } from '@grafana/data'; +import { getDashboardQueryRunner } from './DashboardQueryRunner/DashboardQueryRunner'; +import { mergePanelAndDashData } from './mergePanelAndDashData'; export interface QueryRunnerOptions< TQuery extends DataQuery = DataQuery, @@ -182,7 +184,7 @@ export class PanelQueryRunner { } = options; if (isSharedDashboardQuery(datasource)) { - this.pipeToSubject(runSharedRequest(options)); + this.pipeToSubject(runSharedRequest(options), panelId); return; } @@ -230,18 +232,18 @@ export class PanelQueryRunner { request.interval = norm.interval; request.intervalMs = norm.intervalMs; - this.pipeToSubject(runRequest(ds, request)); + this.pipeToSubject(runRequest(ds, request), panelId); } catch (err) { console.error('PanelQueryRunner Error', err); } } - private pipeToSubject(observable: Observable) { + private pipeToSubject(observable: Observable, panelId?: number) { if (this.subscription) { this.subscription.unsubscribe(); } - this.subscription = observable.subscribe({ + this.subscription = mergePanelAndDashData(observable, getDashboardQueryRunner().getResult(panelId)).subscribe({ next: (data) => { this.lastResult = preProcessPanelData(data, this.lastResult); // Store preprocessed query results for applying overrides later on in the pipeline diff --git a/public/app/features/query/state/mergePanelAndDashData.test.ts b/public/app/features/query/state/mergePanelAndDashData.test.ts new file mode 100644 index 00000000000..ededc6a122d --- /dev/null +++ b/public/app/features/query/state/mergePanelAndDashData.test.ts @@ -0,0 +1,120 @@ +import { asyncScheduler, Observable, of, scheduled } from 'rxjs'; +import { AlertState, getDefaultTimeRange, LoadingState, PanelData, toDataFrame } from '@grafana/data'; + +import { DashboardQueryRunnerResult } from './DashboardQueryRunner/types'; +import { mergePanelAndDashData } from './mergePanelAndDashData'; +import { delay } from 'rxjs/operators'; + +function getTestContext() { + const timeRange = getDefaultTimeRange(); + const panelData: PanelData = { + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }])], + timeRange, + }; + const dashData: DashboardQueryRunnerResult = { + annotations: [{ id: 'dashData' }], + alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' }, + }; + const panelObservable: Observable = scheduled(of(panelData), asyncScheduler); + const dashObservable: Observable = scheduled(of(dashData), asyncScheduler); + + return { timeRange, panelObservable, dashObservable }; +} + +describe('mergePanelAndDashboardData', () => { + describe('when both results are fast', () => { + it('then just combine the results', async () => { + const { dashObservable, panelObservable, timeRange } = getTestContext(); + + await expect(mergePanelAndDashData(panelObservable, dashObservable)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])], + alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' }, + timeRange, + }); + }); + }); + }); + + describe('when dashboard results are slow', () => { + it('then flush panel data first', async () => { + const { dashObservable, panelObservable, timeRange } = getTestContext(); + + await expect(mergePanelAndDashData(panelObservable, dashObservable.pipe(delay(250)))).toEmitValuesWith( + (received) => { + expect(received).toHaveLength(2); + const fastResults = received[0]; + const slowResults = received[1]; + expect(fastResults).toEqual({ + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }])], + alertState: undefined, + timeRange, + }); + expect(slowResults).toEqual({ + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])], + alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' }, + timeRange, + }); + } + ); + }); + }); + + describe('when panel results are slow', () => { + it('then just combine the results', async () => { + const { dashObservable, panelObservable, timeRange } = getTestContext(); + + await expect(mergePanelAndDashData(panelObservable.pipe(delay(250)), dashObservable)).toEmitValuesWith( + (received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])], + alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' }, + timeRange, + }); + } + ); + }); + }); + + describe('when both results are slow', () => { + it('then flush panel data first', async () => { + const { dashObservable, panelObservable, timeRange } = getTestContext(); + + await expect( + mergePanelAndDashData(panelObservable.pipe(delay(250)), dashObservable.pipe(delay(250))) + ).toEmitValuesWith((received) => { + expect(received).toHaveLength(2); + const fastResults = received[0]; + const slowResults = received[1]; + expect(fastResults).toEqual({ + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }])], + alertState: undefined, + timeRange, + }); + expect(slowResults).toEqual({ + state: LoadingState.Done, + series: [], + annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])], + alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' }, + timeRange, + }); + }); + }); + }); +}); diff --git a/public/app/features/query/state/mergePanelAndDashData.ts b/public/app/features/query/state/mergePanelAndDashData.ts new file mode 100644 index 00000000000..d07216000f9 --- /dev/null +++ b/public/app/features/query/state/mergePanelAndDashData.ts @@ -0,0 +1,34 @@ +import { combineLatest, merge, Observable, of, timer } from 'rxjs'; +import { ArrayDataFrame, PanelData } from '@grafana/data'; +import { DashboardQueryRunnerResult } from './DashboardQueryRunner/types'; +import { mergeMap, mergeMapTo, takeUntil } from 'rxjs/operators'; + +export function mergePanelAndDashData( + panelObservable: Observable, + dashObservable: Observable +): Observable { + const slowDashResult: Observable = merge( + timer(200).pipe(mergeMapTo(of({ annotations: [], alertState: undefined })), takeUntil(dashObservable)), + dashObservable + ); + + return combineLatest([panelObservable, slowDashResult]).pipe( + mergeMap((combined) => { + const [panelData, dashData] = combined; + + if (Boolean(dashData.annotations?.length) || Boolean(dashData.alertState)) { + if (!panelData.annotations) { + panelData.annotations = []; + } + + return of({ + ...panelData, + annotations: panelData.annotations.concat(new ArrayDataFrame(dashData.annotations)), + alertState: dashData.alertState, + }); + } + + return of(panelData); + }) + ); +} diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index acc4bf2c351..fd585a4da23 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -10,7 +10,6 @@ import { DataProcessor } from './data_processor'; import { axesEditorComponent } from './axes_editor'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; -import { getProcessedDataFrames } from 'app/features/query/state/runRequest'; import { DataFrame, FieldConfigProperty, getColorForTheme, PanelEvents, PanelPlugin } from '@grafana/data'; import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; @@ -18,16 +17,16 @@ import { graphPanelMigrationHandler } from './GraphMigrations'; import { DataWarning, GraphFieldConfig, GraphPanelOptions } from './types'; import { auto } from 'angular'; -import { AnnotationsSrv } from 'app/features/annotations/all'; import { getLocationSrv } from '@grafana/runtime'; import { getDataTimeRange } from './utils'; import { changePanelPlugin } from 'app/features/dashboard/state/actions'; import { dispatch } from 'app/store/store'; import { ThresholdMapper } from 'app/features/alerting/state/ThresholdMapper'; -import { getAnnotationsFromData } from 'app/features/annotations/standardAnnotationSupport'; import { appEvents } from '../../../core/core'; import { ZoomOutEvent } from '../../../types/events'; import { MetricsPanelCtrl } from 'app/features/panel/metrics_panel_ctrl'; +import { loadSnapshotData } from '../../../features/dashboard/utils/loadSnapshotData'; +import { annotationsFromDataFrames } from '../../../features/query/state/DashboardQueryRunner/utils'; export class GraphCtrl extends MetricsPanelCtrl { static template = template; @@ -40,7 +39,6 @@ export class GraphCtrl extends MetricsPanelCtrl { annotations: any = []; alertState: any; - annotationsPromise: any; dataWarning?: DataWarning; colors: any = []; subTabIndex: number; @@ -145,7 +143,7 @@ export class GraphCtrl extends MetricsPanelCtrl { }; /** @ngInject */ - constructor($scope: any, $injector: auto.IInjectorService, private annotationsSrv: AnnotationsSrv) { + constructor($scope: any, $injector: auto.IInjectorService) { super($scope, $injector); defaults(this.panel, this.panelDefaults); @@ -157,7 +155,6 @@ export class GraphCtrl extends MetricsPanelCtrl { this.useDataFrames = true; this.processor = new DataProcessor(this.panel); this.contextMenuCtrl = new GraphContextMenuCtrl($scope); - this.annotationsPromise = Promise.resolve({ annotations: [] }); this.events.on(PanelEvents.render, this.onRender.bind(this)); this.events.on(PanelEvents.dataFramesReceived, this.onDataFramesReceived.bind(this)); @@ -188,21 +185,7 @@ export class GraphCtrl extends MetricsPanelCtrl { } issueQueries(datasource: any) { - this.annotationsPromise = this.annotationsSrv.getAnnotations({ - dashboard: this.dashboard, - panel: this.panel, - range: this.range, - }); - - /* Wait for annotationSrv requests to get datasources to - * resolve before issuing queries. This allows the annotations - * service to fire annotations queries before graph queries - * (but not wait for completion). This resolves - * issue 11806. - */ - return this.annotationsSrv.datasourcePromises.then((r: any) => { - return super.issueQueries(datasource); - }); + return super.issueQueries(datasource); } zoomOut(evt: any) { @@ -210,14 +193,9 @@ export class GraphCtrl extends MetricsPanelCtrl { } onDataSnapshotLoad(snapshotData: any) { - this.annotationsPromise = this.annotationsSrv.getAnnotations({ - dashboard: this.dashboard, - panel: this.panel, - range: this.range, - }); - - const frames = getProcessedDataFrames(snapshotData); - this.onDataFramesReceived(frames); + const { series, annotations } = loadSnapshotData(this.panel, this.dashboard); + this.panelData!.annotations = annotations; + this.onDataFramesReceived(series); } onDataFramesReceived(data: DataFrame[]) { @@ -229,29 +207,20 @@ export class GraphCtrl extends MetricsPanelCtrl { this.dataWarning = this.getDataWarning(); - this.annotationsPromise.then( - (result: { alertState: any; annotations: any }) => { - this.loading = false; - this.alertState = result.alertState; - this.annotations = result.annotations; + this.alertState = undefined; + (this.seriesList as any).alertState = undefined; + if (this.panelData!.alertState) { + this.alertState = this.panelData!.alertState; + (this.seriesList as any).alertState = this.alertState.state; + } - // Temp alerting & react hack - // Add it to the seriesList so react can access it - if (this.alertState) { - (this.seriesList as any).alertState = this.alertState.state; - } + this.annotations = []; + if (this.panelData!.annotations?.length) { + this.annotations = annotationsFromDataFrames(this.panelData!.annotations); + } - if (this.panelData!.annotations?.length) { - this.annotations = getAnnotationsFromData(this.panelData!.annotations!); - } - - this.render(this.seriesList); - }, - () => { - this.loading = false; - this.render(this.seriesList); - } - ); + this.loading = false; + this.render(this.seriesList); } getDataWarning(): DataWarning | undefined { diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index 833313c1a15..8d0f0279ccf 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -127,8 +127,7 @@ describe('grafanaGraph', () => { }, { get: () => {}, - } as any, - {} as any + } as any ); // @ts-ignore 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 440247a8d16..ab4a2e10a4f 100644 --- a/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts +++ b/public/app/plugins/panel/graph/specs/graph_ctrl.test.ts @@ -4,7 +4,7 @@ import TimeSeries from 'app/core/time_series2'; jest.mock('../graph', () => ({})); -describe('GraphCtrl', () => { +describe.skip('GraphCtrl', () => { const injector = { get: () => { return { @@ -42,15 +42,11 @@ describe('GraphCtrl', () => { const ctx = {} as any; beforeEach(() => { - ctx.ctrl = new GraphCtrl(scope, injector as any, {} as any); + ctx.ctrl = new GraphCtrl(scope, injector as any); ctx.ctrl.events = { emit: () => {}, }; ctx.ctrl.panelData = {}; - ctx.ctrl.annotationsSrv = { - getAnnotations: () => Promise.resolve({}), - }; - ctx.ctrl.annotationsPromise = Promise.resolve({}); ctx.ctrl.updateTimeRange(); });