diff --git a/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx
new file mode 100644
index 00000000000..a3147acba91
--- /dev/null
+++ b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx
@@ -0,0 +1,72 @@
+import { AnnotationQuery, EventBus, GrafanaThemeV2 } from '@grafana/data';
+import React, { useEffect, useState } from 'react';
+import { getDashboardQueryRunner } from '../../../query/state/DashboardQueryRunner/DashboardQueryRunner';
+import { AnnotationQueryFinished, AnnotationQueryStarted } from '../../../../types/events';
+import { InlineField, InlineSwitch, useStyles2 } from '@grafana/ui';
+import { LoadingIndicator } from '@grafana/ui/src/components/PanelChrome/LoadingIndicator';
+import { css } from '@emotion/css';
+
+export interface AnnotationPickerProps {
+ events: EventBus;
+ annotation: AnnotationQuery;
+ onEnabledChanged: (annotation: AnnotationQuery) => void;
+}
+
+export const AnnotationPicker = ({ annotation, events, onEnabledChanged }: AnnotationPickerProps): JSX.Element => {
+ const [loading, setLoading] = useState(false);
+ const styles = useStyles2(getStyles);
+ const onCancel = () => getDashboardQueryRunner().cancel(annotation);
+
+ useEffect(() => {
+ const started = events.subscribe(AnnotationQueryStarted, (event) => {
+ if (event.payload === annotation) {
+ setLoading(true);
+ }
+ });
+ const stopped = events.subscribe(AnnotationQueryFinished, (event) => {
+ if (event.payload === annotation) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ started.unsubscribe();
+ stopped.unsubscribe();
+ };
+ });
+
+ return (
+
+
+ <>
+ onEnabledChanged(annotation)} disabled={loading} />
+
+
+
+ >
+
+
+ );
+};
+
+function getStyles(theme: GrafanaThemeV2) {
+ return {
+ annotation: css`
+ display: inline-block;
+ margin-right: ${theme.spacing(1)};
+
+ .fa-caret-down {
+ font-size: 75%;
+ padding-left: ${theme.spacing(1)};
+ }
+
+ .gf-form-inline .gf-form {
+ margin-bottom: 0;
+ }
+ `,
+ indicator: css`
+ align-self: center;
+ padding: 0 ${theme.spacing(0.5)};
+ `,
+ };
+}
diff --git a/public/app/features/dashboard/components/SubMenu/Annotations.tsx b/public/app/features/dashboard/components/SubMenu/Annotations.tsx
index da2026e6040..8f0d30afe5d 100644
--- a/public/app/features/dashboard/components/SubMenu/Annotations.tsx
+++ b/public/app/features/dashboard/components/SubMenu/Annotations.tsx
@@ -1,14 +1,15 @@
import React, { FunctionComponent, useEffect, useState } from 'react';
-import { AnnotationQuery } from '@grafana/data';
-import { InlineField, InlineSwitch } from '@grafana/ui';
+import { AnnotationQuery, EventBus } from '@grafana/data';
+import { AnnotationPicker } from './AnnotationPicker';
interface Props {
+ events: EventBus;
annotations: AnnotationQuery[];
onAnnotationChanged: (annotation: any) => void;
}
-export const Annotations: FunctionComponent = ({ annotations, onAnnotationChanged }) => {
- const [visibleAnnotations, setVisibleAnnotations] = useState([]);
+export const Annotations: FunctionComponent = ({ annotations, onAnnotationChanged, events }) => {
+ const [visibleAnnotations, setVisibleAnnotations] = useState([]);
useEffect(() => {
setVisibleAnnotations(annotations.filter((annotation) => annotation.hide !== true));
}, [annotations]);
@@ -19,15 +20,14 @@ export const Annotations: FunctionComponent = ({ annotations, onAnnotatio
return (
<>
- {visibleAnnotations.map((annotation: any) => {
- return (
-
-
- onAnnotationChanged(annotation)} />
-
-
- );
- })}
+ {visibleAnnotations.map((annotation) => (
+
+ ))}
>
);
};
diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx
index 9740119cec0..2fe5da1470e 100644
--- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx
+++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx
@@ -48,7 +48,11 @@ class SubMenuUnConnected extends PureComponent {
return (
-
+
{dashboard &&
}
diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts
index 112af5d436a..470c4ad491e 100644
--- a/public/app/features/dashboard/state/initDashboard.test.ts
+++ b/public/app/features/dashboard/state/initDashboard.test.ts
@@ -1,10 +1,12 @@
+import { Subject } from 'rxjs';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
+import { locationService, setEchoSrv } from '@grafana/runtime';
+
import { initDashboard, InitDashboardArgs } from './initDashboard';
import { DashboardInitPhase, DashboardRoutes } from 'app/types';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { dashboardInitCompleted, dashboardInitFetching, dashboardInitServices } from './reducers';
-import { locationService, setEchoSrv } from '@grafana/runtime';
import { Echo } from '../../../core/services/echo/Echo';
import { variableAdapters } from 'app/features/variables/adapters';
import { createConstantVariableAdapter } from 'app/features/variables/constant/adapter';
@@ -90,6 +92,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
getResult: emptyResult,
run: jest.fn(),
cancel: () => undefined,
+ cancellations: () => new Subject
(),
destroy: () => undefined,
}));
diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts
index 1ac864d5841..60042baccb3 100644
--- a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts
+++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts
@@ -1,13 +1,27 @@
-import { throwError } from 'rxjs';
+import { Subject, 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';
+import { createDashboardQueryRunner, setDashboardQueryRunnerFactory } from './DashboardQueryRunner';
+import { emptyResult } from './utils';
+import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorkerResult } from './types';
+import { AnnotationQuery } from '@grafana/data';
+import { delay } from 'rxjs/operators';
function getTestContext() {
jest.clearAllMocks();
+ const cancellations = new Subject();
+ setDashboardQueryRunnerFactory(() => ({
+ getResult: emptyResult,
+ run: () => undefined,
+ cancel: () => undefined,
+ cancellations: () => cancellations,
+ destroy: () => undefined,
+ }));
+ createDashboardQueryRunner({} as any);
const executeAnnotationQueryMock = jest
.spyOn(annotationsSrv, 'executeAnnotationQuery')
.mockReturnValue(toAsyncOfResult({ events: [{ id: 'NextGen' }] }));
@@ -32,7 +46,28 @@ function getTestContext() {
setDataSourceSrv(dataSourceSrvMock);
const options = getDefaultOptions();
- return { options, annotationQueryMock, executeAnnotationQueryMock };
+ return { options, annotationQueryMock, executeAnnotationQueryMock, cancellations };
+}
+
+function expectOnResults(args: {
+ worker: AnnotationsWorker;
+ options: DashboardQueryRunnerOptions;
+ done: jest.DoneCallback;
+ expect: (results: DashboardQueryRunnerWorkerResult) => void;
+}) {
+ const { worker, done, options, expect: expectCallback } = args;
+ const subscription = worker.work(options).subscribe({
+ next: (value) => {
+ try {
+ expectCallback(value);
+ subscription.unsubscribe();
+ done();
+ } catch (err) {
+ subscription.unsubscribe();
+ done.fail(err);
+ }
+ },
+ });
}
describe('AnnotationsWorker', () => {
@@ -142,17 +177,21 @@ describe('AnnotationsWorker', () => {
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' }));
+ describe('when run is called with correct props and a worker is cancelled', () => {
+ it('then it should return the correct results', (done) => {
+ const { options, executeAnnotationQueryMock, annotationQueryMock, cancellations } = getTestContext();
+ executeAnnotationQueryMock.mockReturnValueOnce(
+ toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(10000))
+ );
- await expect(worker.work(options)).toEmitValuesWith((received) => {
- expect(received).toHaveLength(1);
- const result = received[0];
- expect(result).toEqual({
+ expectOnResults({
+ worker,
+ options,
+ done,
+ expect: (results) => {
+ expect(results).toEqual({
alertStates: [],
annotations: [
{
@@ -173,24 +212,63 @@ describe('AnnotationsWorker', () => {
});
expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1);
expect(annotationQueryMock).toHaveBeenCalledTimes(1);
+ },
+ });
+
+ setTimeout(() => {
+ // call to async needs to be async or the cancellation will be called before any of the runners have started
+ cancellations.next(options.dashboard.annotations.list[1]);
+ }, 100);
+ });
+ });
+
+ 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' }));
+ 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);
- });
+ 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
index 54f556a1e77..c82a3e0ca0c 100644
--- a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.ts
+++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.ts
@@ -1,6 +1,6 @@
import { cloneDeep } from 'lodash';
import { from, merge, Observable, of } from 'rxjs';
-import { map, mergeAll, mergeMap, reduce } from 'rxjs/operators';
+import { filter, finalize, map, mergeAll, mergeMap, reduce, takeUntil } from 'rxjs/operators';
import { getDataSourceSrv } from '@grafana/runtime';
import { AnnotationQuery, DataSourceApi } from '@grafana/data';
@@ -13,6 +13,8 @@ import {
import { emptyResult, translateQueryResult } from './utils';
import { LegacyAnnotationQueryRunner } from './LegacyAnnotationQueryRunner';
import { AnnotationsQueryRunner } from './AnnotationsQueryRunner';
+import { AnnotationQueryFinished, AnnotationQueryStarted } from '../../../../types/events';
+import { getDashboardQueryRunner } from './DashboardQueryRunner';
export class AnnotationsWorker implements DashboardQueryRunnerWorker {
constructor(
@@ -43,7 +45,14 @@ export class AnnotationsWorker implements DashboardQueryRunnerWorker {
return of([]);
}
+ dashboard.events.publish(new AnnotationQueryStarted(annotation));
+
return runner.run({ annotation, datasource, dashboard, range }).pipe(
+ takeUntil(
+ getDashboardQueryRunner()
+ .cancellations()
+ .pipe(filter((a) => a === annotation))
+ ),
map((results) => {
// store response in annotation object if this is a snapshot call
if (dashboard.snapshot) {
@@ -51,6 +60,9 @@ export class AnnotationsWorker implements DashboardQueryRunnerWorker {
}
// translate result
return translateQueryResult(annotation, results);
+ }),
+ finalize(() => {
+ dashboard.events.publish(new AnnotationQueryFinished(annotation));
})
);
})
diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts
index a35ca2d1c45..1e585cd89c9 100644
--- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts
+++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts
@@ -206,7 +206,7 @@ describe('DashboardQueryRunnerImpl', () => {
});
describe('when calling cancel', () => {
- it('then it should cancel previous run', (done) => {
+ it('then it should cancel matching workers', (done) => {
const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext();
executeAnnotationQueryMock.mockReturnValueOnce(
toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(10000))
@@ -219,16 +219,19 @@ describe('DashboardQueryRunnerImpl', () => {
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);
+ const { alertState, annotations } = getExpectedForAllResult();
+ expect(results).toEqual({ alertState, annotations: [annotations[0], annotations[2]] });
+ expect(annotationQueryMock).toHaveBeenCalledTimes(1);
+ expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1);
expect(getMock).toHaveBeenCalledTimes(1);
},
});
runner.run(options);
- runner.cancel();
+ setTimeout(() => {
+ // call to async needs to be async or the cancellation will be called before any of the workers have started
+ runner.cancel(options.dashboard.annotations.list[1]);
+ }, 100);
});
});
});
diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts
index 71bda6a8d64..06be38c5c43 100644
--- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts
+++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts
@@ -1,5 +1,6 @@
-import { merge, Observable, race, Subject, Unsubscribable } from 'rxjs';
+import { merge, Observable, Subject, Unsubscribable } from 'rxjs';
import { map, mergeAll, reduce, share, takeUntil } from 'rxjs/operators';
+import { AnnotationQuery } from '@grafana/data';
import { dedupAnnotations } from 'app/features/annotations/events_processing';
import {
@@ -20,7 +21,7 @@ import { RefreshEvent } from '../../../../types/events';
class DashboardQueryRunnerImpl implements DashboardQueryRunner {
private readonly results: Subject;
private readonly runs: Subject;
- private readonly cancellations: Subject;
+ private readonly cancellationStream: Subject;
private readonly runsSubscription: Unsubscribable;
private readonly eventsSubscription: Unsubscribable;
@@ -40,7 +41,7 @@ class DashboardQueryRunnerImpl implements DashboardQueryRunner {
this.executeRun = this.executeRun.bind(this);
this.results = new Subject();
this.runs = new Subject();
- this.cancellations = new Subject();
+ this.cancellationStream = 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() });
@@ -70,7 +71,7 @@ class DashboardQueryRunnerImpl implements DashboardQueryRunner {
merge(observables)
.pipe(
- takeUntil(race(this.runs.asObservable(), this.cancellations.asObservable())),
+ takeUntil(this.runs.asObservable()),
mergeAll(),
reduce((acc, value) => {
// should we use scan or reduce here
@@ -87,15 +88,18 @@ class DashboardQueryRunnerImpl implements DashboardQueryRunner {
});
}
- cancel(): void {
- this.cancellations.next(1);
- this.results.next({ annotations: [], alertStates: [] });
+ cancel(annotation: AnnotationQuery): void {
+ this.cancellationStream.next(annotation);
+ }
+
+ cancellations(): Observable {
+ return this.cancellationStream.asObservable().pipe(share());
}
destroy(): void {
this.results.complete();
this.runs.complete();
- this.cancellations.complete();
+ this.cancellationStream.complete();
this.runsSubscription.unsubscribe();
this.eventsSubscription.unsubscribe();
}
diff --git a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts
index 8b666c8d732..ab7bc4b92e7 100644
--- a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts
+++ b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts
@@ -1,4 +1,4 @@
-import { asyncScheduler, Observable, of, scheduled, Subject } from 'rxjs';
+import { asyncScheduler, Observable, of, scheduled } from 'rxjs';
import { DashboardQueryRunnerOptions } from './types';
import { AnnotationEvent, getDefaultTimeRange } from '@grafana/data';
@@ -48,7 +48,10 @@ export function getDefaultOptions(): DashboardQueryRunnerOptions {
getAnnotation({ enable: false, useSnapshotData: true }),
],
},
- events: new Subject(),
+ events: {
+ subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
+ publish: jest.fn(),
+ },
};
const range = getDefaultTimeRange();
diff --git a/public/app/features/query/state/DashboardQueryRunner/types.ts b/public/app/features/query/state/DashboardQueryRunner/types.ts
index c57392539f3..4762b032727 100644
--- a/public/app/features/query/state/DashboardQueryRunner/types.ts
+++ b/public/app/features/query/state/DashboardQueryRunner/types.ts
@@ -16,7 +16,8 @@ export interface DashboardQueryRunnerResult {
export interface DashboardQueryRunner {
run: (options: DashboardQueryRunnerOptions) => void;
getResult: (panelId?: number) => Observable;
- cancel: () => void;
+ cancel: (annotation: AnnotationQuery) => void;
+ cancellations: () => Observable;
destroy: () => void;
}
diff --git a/public/app/features/query/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts
index cc7cd714f34..37a32d198b9 100644
--- a/public/app/features/query/state/PanelQueryRunner.test.ts
+++ b/public/app/features/query/state/PanelQueryRunner.test.ts
@@ -1,5 +1,6 @@
const applyFieldOverridesMock = jest.fn(); // needs to be first in this file
+import { Subject } from 'rxjs';
// Importing this way to be able to spy on grafana/data
import * as grafanaData from '@grafana/data';
import { DashboardModel } from '../../dashboard/state/index';
@@ -95,6 +96,7 @@ function describeQueryRunnerScenario(
getResult: emptyResult,
run: () => undefined,
cancel: () => undefined,
+ cancellations: () => new Subject(),
destroy: () => undefined,
}));
createDashboardQueryRunner({} as any);
diff --git a/public/app/types/events.ts b/public/app/types/events.ts
index 0a8ecdb1079..9c8835c1cbd 100644
--- a/public/app/types/events.ts
+++ b/public/app/types/events.ts
@@ -1,4 +1,11 @@
-import { BusEventBase, BusEventWithPayload, eventFactory, GrafanaThemeV2, TimeRange } from '@grafana/data';
+import {
+ AnnotationQuery,
+ BusEventBase,
+ BusEventWithPayload,
+ eventFactory,
+ GrafanaThemeV2,
+ TimeRange,
+} from '@grafana/data';
import { IconName } from '@grafana/ui';
/**
@@ -184,3 +191,11 @@ export class HideModalEvent extends BusEventBase {
export class DashboardSavedEvent extends BusEventBase {
static type = 'dashboard-saved';
}
+
+export class AnnotationQueryStarted extends BusEventWithPayload {
+ static type = 'annotation-query-started';
+}
+
+export class AnnotationQueryFinished extends BusEventWithPayload {
+ static type = 'annotation-query-finished';
+}