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
This commit is contained in:
Hugo Häggmark 2021-04-26 06:13:03 +02:00 committed by GitHub
parent cdc6f4c2ac
commit 19739f4af2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1781 additions and 118 deletions

View File

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

View File

@ -32,3 +32,4 @@ export * from './variables';
export * from './geometry';
export { isUnsignedPluginSignature } from './pluginSignature';
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';
export * from './alerts';

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

@ -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<Props, State> {
}
this.props.initDashboard({
$injector: getLegacyAngularInjector(),
urlSlug: match.params.slug,
urlUid: match.params.uid,
urlType: match.params.type,

View File

@ -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<Props, State> {
const { match, route } = this.props;
this.props.initDashboard({
$injector: getLegacyAngularInjector(),
urlSlug: match.params.slug,
urlUid: match.params.uid,
urlType: match.params.type,

View File

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

View File

@ -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', () => {

View File

@ -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<void> {
// 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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AnnotationEvent[]> {
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)
);
}
}

View File

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

View File

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

View File

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

View File

@ -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<DashboardQueryRunnerWorkerResult>;
private readonly runs: Subject<DashboardQueryRunnerOptions>;
private readonly cancellations: Subject<any>;
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<DashboardQueryRunnerWorkerResult>();
this.runs = new Subject<DashboardQueryRunnerOptions>();
this.cancellations = new Subject<any>();
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<DashboardQueryRunnerResult> {
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;
}

View File

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

View File

@ -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<AnnotationEvent[]> {
if (!this.canRun(datasource)) {
return of([]);
}
return from(datasource.annotationQuery!({ range, rangeRaw: range.raw, annotation, dashboard })).pipe(
catchError(handleAnnotationQueryRunnerError)
);
}
}

View File

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

View File

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

View File

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

View File

@ -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<DashboardQueryRunnerResult>;
cancel: () => void;
destroy: () => void;
}
export interface DashboardQueryRunnerWorkerResult {
annotations: AnnotationEvent[];
alertStates: AlertStateInfo[];
}
export interface DashboardQueryRunnerWorker {
canWork: (options: DashboardQueryRunnerOptions) => boolean;
work: (options: DashboardQueryRunnerOptions) => Observable<DashboardQueryRunnerWorkerResult>;
}
export interface AnnotationQueryRunnerOptions extends DashboardQueryRunnerOptions {
datasource: DataSourceApi;
annotation: AnnotationQuery;
}
export interface AnnotationQueryRunner {
canRun: (datasource: DataSourceApi) => boolean;
run: (options: AnnotationQueryRunnerOptions) => Observable<AnnotationEvent[]>;
}

View File

@ -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<AnnotationEvent[]> {
if (err.cancelled) {
return of([]);
}
notifyWithError('AnnotationQueryRunner failed', err);
return of([]);
}
export const emptyResult: () => Observable<DashboardQueryRunnerWorkerResult> = () =>
of({ annotations: [], alertStates: [] });
export function handleDashboardQueryRunnerWorkerError(err: any): Observable<DashboardQueryRunnerWorkerResult> {
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<AnnotationEvent>(frame);
for (let index = 0; index < frame.length; index++) {
const annotation = cloneDeep(view.get(index));
annotations.push(annotation);
}
}
return annotations;
}

View File

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

View File

@ -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<PanelData>) {
private pipeToSubject(observable: Observable<PanelData>, 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

View File

@ -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<PanelData> = scheduled(of(panelData), asyncScheduler);
const dashObservable: Observable<DashboardQueryRunnerResult> = 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,
});
});
});
});
});

View File

@ -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<PanelData>,
dashObservable: Observable<DashboardQueryRunnerResult>
): Observable<PanelData> {
const slowDashResult: Observable<DashboardQueryRunnerResult> = 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);
})
);
}

View File

@ -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);
});
}
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,30 +207,21 @@ 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;
// Temp alerting & react hack
// Add it to the seriesList so react can access it
if (this.alertState) {
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;
}
this.annotations = [];
if (this.panelData!.annotations?.length) {
this.annotations = getAnnotationsFromData(this.panelData!.annotations!);
this.annotations = annotationsFromDataFrames(this.panelData!.annotations);
}
this.render(this.seriesList);
},
() => {
this.loading = false;
this.render(this.seriesList);
}
);
}
getDataWarning(): DataWarning | undefined {
const datapointsCount = this.seriesList.reduce((prev, series) => {

View File

@ -127,8 +127,7 @@ describe('grafanaGraph', () => {
},
{
get: () => {},
} as any,
{} as any
} as any
);
// @ts-ignore

View File

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