From 4ebacdbdc297868bdb6b106e86082f6c70ac0ddc Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:21:41 +0200 Subject: [PATCH] Saved Queries: Expand saved queries in annotations (#111023) * Base logic to show saved query buttons in annotations, v1 dashboards only * Add support for v2 * remove unnecessary function * add TODO for checking logic later and clean up code * Fix issue on cross-datasource replacement * remove unnecesary async * refactor code, call the prepareAnnotation in savedQueryUtils instead of relying on verifyDatasource * Add unit tests * Refactor code, add comments for context * Add missing unit tests * Fix issue of skipping prepareAnnotation always, added skipping only for replace saved queries scenarios, added unit tests * implement datasource-agnostic query normalization --- .../AnnotationQueryEditorActionsWrapper.tsx | 41 ++ .../StandardAnnotationQueryEditor.test.tsx | 241 ++++++++- .../StandardAnnotationQueryEditor.tsx | 64 ++- .../annotations/utils/savedQueryUtils.test.ts | 473 ++++++++++++++++++ .../annotations/utils/savedQueryUtils.ts | 130 +++++ 5 files changed, 936 insertions(+), 13 deletions(-) create mode 100644 public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx create mode 100644 public/app/features/annotations/utils/savedQueryUtils.test.ts create mode 100644 public/app/features/annotations/utils/savedQueryUtils.ts diff --git a/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx b/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx new file mode 100644 index 00000000000..f2e5e141297 --- /dev/null +++ b/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx @@ -0,0 +1,41 @@ +import { ReactElement } from 'react'; + +import { AnnotationQuery, CoreApp, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; +import { Stack } from '@grafana/ui'; +import { useQueryLibraryContext } from 'app/features/explore/QueryLibrary/QueryLibraryContext'; + +import { getDataQueryFromAnnotationForSavedQueries } from '../utils/savedQueryUtils'; + +interface Props { + children: ReactElement; + annotation: AnnotationQuery; + datasource: DataSourceApi; + datasourceInstanceSettings: DataSourceInstanceSettings; + onQueryReplace: (query: DataQuery) => void; +} + +export function AnnotationQueryEditorActionsWrapper({ + children, + annotation, + datasource, + datasourceInstanceSettings, + onQueryReplace, +}: Props) { + const { renderSavedQueryButtons } = useQueryLibraryContext(); + + const savedQueryButtons = renderSavedQueryButtons( + getDataQueryFromAnnotationForSavedQueries(annotation, datasource), + CoreApp.Dashboard, + undefined, + onQueryReplace, + datasourceInstanceSettings?.name ? [datasourceInstanceSettings.name] : [] + ); + + return ( + + {savedQueryButtons} + {children} + + ); +} diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.test.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.test.tsx index d0738045a7f..9c4d52373ee 100644 --- a/public/app/features/annotations/components/StandardAnnotationQueryEditor.test.tsx +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.test.tsx @@ -1,6 +1,11 @@ -import { render } from '@testing-library/react'; +import { render, act } from '@testing-library/react'; +import { createRef } from 'react'; import { AnnotationQuery, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; +import { PromQuery } from '@grafana/prometheus'; +import { DataQuery } from '@grafana/schema'; + +import { updateAnnotationFromSavedQuery, getDataQueryFromAnnotationForSavedQueries } from '../utils/savedQueryUtils'; import StandardAnnotationQueryEditor, { Props as EditorProps } from './StandardAnnotationQueryEditor'; @@ -28,6 +33,22 @@ jest.mock('app/features/dashboard/services/TimeSrv', () => ({ }), })); +jest.mock('../utils/savedQueryUtils', () => ({ + updateAnnotationFromSavedQuery: jest.fn(), + getDataQueryFromAnnotationForSavedQueries: jest.fn(), +})); + +jest.mock('app/features/explore/QueryLibrary/QueryLibraryContext', () => ({ + useQueryLibraryContext: jest.fn().mockReturnValue({ + renderSavedQueryButtons: jest.fn().mockReturnValue(null), + }), +})); + +jest.mock('../standardAnnotationSupport', () => ({ + ...jest.requireActual('../standardAnnotationSupport'), + shouldUseLegacyRunner: jest.fn().mockReturnValue(false), +})); + describe('StandardAnnotationQueryEditor', () => { it('should fill out a default query if it is defined and pass it to the Query Editor', () => { const { props } = setup({ @@ -346,4 +367,222 @@ describe('StandardAnnotationQueryEditor', () => { }) ); }); + + describe('verifyDataSource behavior', () => { + it('should always run prepareAnnotation for proper query formatting', () => { + const mockPrepareAnnotation = jest.fn((annotation: AnnotationQuery) => annotation); + + setup({ + annotation: { + name: 'test annotation', + datasource: { uid: 'prometheus-uid', type: 'prometheus' }, + target: { refId: 'A', expr: 'up' } as PromQuery, + enable: true, + iconColor: 'green', + } as AnnotationQuery, + datasource: { + uid: 'testdata-uid', + type: 'testdata', // Different datasource + annotations: { + QueryEditor: jest.fn(() =>
Editor
), + prepareAnnotation: mockPrepareAnnotation, + }, + } as unknown as DataSourceApi, + }); + + // The component should always call prepareAnnotation to ensure proper query structure + expect(mockPrepareAnnotation).toHaveBeenCalledTimes(1); + }); + + it('should run prepareAnnotation for v2 annotations with query.spec', () => { + const mockPrepareAnnotation = jest.fn((annotation: AnnotationQuery) => annotation); + + setup({ + annotation: { + name: 'v2 test annotation', + datasource: { uid: 'prometheus-uid', type: 'prometheus' }, + query: { + kind: 'prometheus', + spec: { refId: 'A', expr: 'up' } as PromQuery, + }, + enable: true, + iconColor: 'green', + } as AnnotationQuery, + datasource: { + uid: 'testdata-uid', + type: 'testdata', // Different datasource + annotations: { + QueryEditor: jest.fn(() =>
Editor
), + prepareAnnotation: mockPrepareAnnotation, + }, + } as unknown as DataSourceApi, + }); + + // The component should call prepareAnnotation to ensure proper query structure + expect(mockPrepareAnnotation).toHaveBeenCalledTimes(1); + }); + + it('should run prepareAnnotation for clean annotations', () => { + const mockPrepareAnnotation = jest.fn((annotation: AnnotationQuery) => annotation); + + setup({ + annotation: { + name: 'clean annotation', + datasource: { uid: 'prometheus-uid', type: 'prometheus' }, + // No target field = clean annotation + enable: true, + iconColor: 'green', + } as AnnotationQuery, + datasource: { + uid: 'testdata-uid', + type: 'testdata', // Different datasource + annotations: { + QueryEditor: jest.fn(() =>
Editor
), + prepareAnnotation: mockPrepareAnnotation, + }, + } as unknown as DataSourceApi, + }); + + // The component should call prepareAnnotation + expect(mockPrepareAnnotation).toHaveBeenCalledTimes(1); + }); + + it('should run prepareAnnotation when datasources match', () => { + const mockPrepareAnnotation = jest.fn((annotation: AnnotationQuery) => annotation); + + setup({ + annotation: { + name: 'matching datasource annotation', + datasource: { uid: 'prometheus-uid', type: 'prometheus' }, + target: { refId: 'A', expr: 'up' } as PromQuery, + enable: true, + iconColor: 'green', + } as AnnotationQuery, + datasource: { + uid: 'prometheus-uid', + type: 'prometheus', // Matches annotation datasource + annotations: { + QueryEditor: jest.fn(() =>
Editor
), + prepareAnnotation: mockPrepareAnnotation, + }, + } as unknown as DataSourceApi, + }); + + // Should always run prepareAnnotation + expect(mockPrepareAnnotation).toHaveBeenCalledTimes(1); + }); + + it('should skip prepareAnnotation on next verifyDataSource call after onQueryReplace', async () => { + // Mocking all dependencies + const mockPrepareAnnotation = jest.fn((annotation: AnnotationQuery) => annotation); + const mockOnChange = jest.fn(); + const mockUpdateAnnotation = updateAnnotationFromSavedQuery as jest.MockedFunction< + typeof updateAnnotationFromSavedQuery + >; + + // mocks the updateAnnotationFromSavedQuery to return a prepared annotation + const mockGetDataQuery = getDataQueryFromAnnotationForSavedQueries as jest.MockedFunction< + typeof getDataQueryFromAnnotationForSavedQueries + >; + + const originalAnnotation = { + name: 'test annotation', + datasource: { uid: 'prometheus-uid', type: 'prometheus' }, + target: { refId: 'A', expr: 'up' } as PromQuery, + enable: true, + iconColor: 'green', + } as AnnotationQuery; + + // this is the method a datasource could implement + const preparedAnnotation = { + name: 'test annotation', + datasource: { uid: 'loki-uid', type: 'loki' }, + target: { refId: 'B', expr: '{job="test"}' }, + enable: true, + iconColor: 'green', + } as AnnotationQuery; + + mockUpdateAnnotation.mockResolvedValue(preparedAnnotation); + + // Mock getDataQueryFromAnnotationForSavedQueries to return a basic query + mockGetDataQuery.mockReturnValue({ + refId: 'Anno', + datasource: originalAnnotation.datasource, + }); + + const componentRef = createRef(); + + const { rerender } = render( +
Editor
), + prepareAnnotation: mockPrepareAnnotation, + }, + } as unknown as DataSourceApi + } + datasourceInstanceSettings={{} as DataSourceInstanceSettings} + onChange={mockOnChange} + /> + ); + + // Initial mount should call prepareAnnotation once + expect(mockPrepareAnnotation).toHaveBeenCalledTimes(1); + + // Call onQueryReplace to set the skipNextVerification flag + const replacedQuery = { + refId: 'B', + expr: '{job="test"}', + datasource: { uid: 'loki-uid', type: 'loki' }, + } as DataQuery; + + await act(async () => { + await componentRef.current!.onQueryReplace(replacedQuery); + }); + + // Verify onQueryReplace worked correctly + expect(mockUpdateAnnotation).toHaveBeenCalledWith(originalAnnotation, replacedQuery); + expect(mockOnChange).toHaveBeenCalledWith(preparedAnnotation); + + // Reset the mock to track subsequent calls + mockPrepareAnnotation.mockClear(); + + // Create a completely new annotation object to ensure componentDidUpdate is triggered + const newAnnotation = { + ...preparedAnnotation, + // Force a new object reference + name: preparedAnnotation.name + ' updated', + }; + + // Now simulate componentDidUpdate by re-rendering with the new annotation + // This should trigger verifyDataSource and with skip logic commented out, should call prepareAnnotation + act(() => { + rerender( +
Editor
), + prepareAnnotation: mockPrepareAnnotation, + }, + } as unknown as DataSourceApi + } + datasourceInstanceSettings={{} as DataSourceInstanceSettings} + onChange={mockOnChange} + /> + ); + }); + + expect(mockPrepareAnnotation).not.toHaveBeenCalled(); + }); + }); }); diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx index 8ba057f65fe..190622bccc4 100644 --- a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -4,7 +4,6 @@ import { lastValueFrom } from 'rxjs'; import { AnnotationEventMappings, AnnotationQuery, - DataQuery, DataSourceApi, DataSourceInstanceSettings, DataSourcePluginContextProvider, @@ -12,6 +11,7 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; +import { DataQuery } from '@grafana/schema'; import { Alert, AlertVariant, Button, Space, Spinner } from '@grafana/ui'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -20,7 +20,9 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { executeAnnotationQuery } from '../executeAnnotationQuery'; import { shouldUseLegacyRunner, shouldUseMappingUI, standardAnnotationSupport } from '../standardAnnotationSupport'; import { AnnotationQueryResponse } from '../types'; +import { updateAnnotationFromSavedQuery } from '../utils/savedQueryUtils'; +import { AnnotationQueryEditorActionsWrapper } from './AnnotationQueryEditorActionsWrapper'; import { AnnotationFieldMapper } from './AnnotationResultMapper'; export interface Props { @@ -33,6 +35,7 @@ export interface Props { interface State { running?: boolean; response?: AnnotationQueryResponse; + skipNextVerification?: boolean; } export default class StandardAnnotationQueryEditor extends PureComponent { @@ -48,16 +51,31 @@ export default class StandardAnnotationQueryEditor extends PureComponent { + const { annotation, onChange } = this.props; + + try { + // Use new async updateAnnotationFromSavedQuery that returns properly prepared annotation + const preparedAnnotation = await updateAnnotationFromSavedQuery(annotation, replacedQuery); + // Set flag to skip next verification since updateAnnotationFromSavedQuery already prepared the annotation + this.setState({ skipNextVerification: true }); + onChange(preparedAnnotation); + } catch (error) { + console.error('Failed to replace annotation query:', error); + // On error, reset the replacing state but don't change the annotation + } + }; + render() { const { datasource, annotation, datasourceInstanceSettings } = this.props; const { response } = this.state; @@ -274,17 +307,24 @@ export default class StandardAnnotationQueryEditor extends PureComponent - + datasourceInstanceSettings={datasourceInstanceSettings} + onQueryReplace={this.onQueryReplace} + > + + {shouldUseMappingUI(datasource) && ( <> diff --git a/public/app/features/annotations/utils/savedQueryUtils.test.ts b/public/app/features/annotations/utils/savedQueryUtils.test.ts new file mode 100644 index 00000000000..eaf31683288 --- /dev/null +++ b/public/app/features/annotations/utils/savedQueryUtils.test.ts @@ -0,0 +1,473 @@ +// unit test for savedQuery utils +import { AnnotationQuery, DataSourceApi, CoreApp, AbstractQuery, AbstractLabelOperator } from '@grafana/data'; +import { PromQuery } from '@grafana/prometheus'; +import { DataQuery } from '@grafana/schema'; + +import { getDataQueryFromAnnotationForSavedQueries, updateAnnotationFromSavedQuery } from './savedQueryUtils'; + +// Mock the runtime service +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + get: jest.fn().mockResolvedValue({ + // Mock getDefaultQuery method for context-aware defaults + getDefaultQuery: jest.fn( + (app: CoreApp): Partial => ({ + refId: 'A', + expr: '', + range: true, + instant: false, + }) + ), + // Mock export/import methods for query normalization + exportToAbstractQueries: jest.fn(async (queries: DataQuery[]): Promise => { + // Mock export: strip context properties, keep core content + return queries.map( + (query): AbstractQuery => ({ + refId: query.refId, + labelMatchers: [ + { name: '__name__', operator: AbstractLabelOperator.Equal, value: (query as PromQuery).expr || 'up' }, + ], + }) + ); + }), + importFromAbstractQueries: jest.fn(async (abstractQueries: AbstractQuery[]): Promise => { + // Mock import: rebuild with appropriate defaults + return abstractQueries.map( + (abstractQuery): PromQuery => ({ + refId: abstractQuery.refId, + expr: abstractQuery.labelMatchers?.[0]?.value || 'up', + range: true, // Dashboard default + }) + ); + }), + annotations: { + prepareAnnotation: (annotation: AnnotationQuery) => { + // Mock realistic Prometheus preparation logic based on actual implementation + // Handle legacy properties that might exist on old annotations + const legacyAnnotation = annotation as AnnotationQuery & { expr?: string; step?: string; refId?: string }; + + // Initialize target if it doesn't exist (Prometheus always creates target) + if (!legacyAnnotation.target) { + legacyAnnotation.target = { + expr: '', + refId: 'Anno', + } as PromQuery; + } + + // Cast target to PromQuery for type safety + const currentTarget = legacyAnnotation.target as PromQuery; + + // Create a new target, preserving existing values when present + legacyAnnotation.target = { + ...currentTarget, + refId: currentTarget.refId || legacyAnnotation.refId || 'Anno', + expr: currentTarget.expr || legacyAnnotation.expr || '', + interval: currentTarget.interval || legacyAnnotation.step || '', + } as PromQuery; + + // Remove properties that have been transferred to target + delete legacyAnnotation.expr; + delete legacyAnnotation.step; + delete legacyAnnotation.refId; + + return legacyAnnotation; + }, + }, + }), + }), +})); + +describe('savedQueryUtils', () => { + describe('getDataQueryFromAnnotationForSavedQueries', () => { + it('should return a DataQuery object', () => { + const annotationToSave: AnnotationQuery = { + datasource: { + type: 'prometheus', + uid: 'prometheus', + }, + target: { + refId: 'Anno', + expr: 'test', + lines: 10, + } as PromQuery, + enable: true, + iconColor: 'red', + hide: false, + name: 'super annotation prom', + }; + + const datasource = { + uid: 'prometheus', + type: 'prometheus', + annotations: { + prepareAnnotation: (annotation: AnnotationQuery) => annotation, + }, + } as unknown as DataSourceApi; + + const result = getDataQueryFromAnnotationForSavedQueries(annotationToSave, datasource); + expect(result).toEqual({ + refId: 'Anno', + expr: 'test', + lines: 10, + datasource: { + type: 'prometheus', + uid: 'prometheus', + }, + }); + }); + + it('should handle v2 dashboard annotations with query.spec', () => { + const v2Annotation: AnnotationQuery = { + name: 'v2 annotation', + query: { + kind: 'prometheus', + spec: { + refId: 'A', + expr: 'rate(http_requests_total[5m])', + legendFormat: '{{method}}', + } as PromQuery, + }, + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + enable: true, + iconColor: 'blue', + hide: false, + }; + + const datasource = { + uid: 'prometheus-uid', + type: 'prometheus', + annotations: { + getDefaultQuery: () => ({ refId: 'Anno' }), + }, + } as unknown as DataSourceApi; + + const result = getDataQueryFromAnnotationForSavedQueries(v2Annotation, datasource); + + expect(result).toEqual({ + refId: 'A', + expr: 'rate(http_requests_total[5m])', + legendFormat: '{{method}}', + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + }); + }); + + it('should use default query when no target or spec exists', () => { + const annotationWithoutQuery: AnnotationQuery = { + name: 'empty annotation', + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + enable: true, + iconColor: 'green', + hide: false, + }; + + const datasource = { + uid: 'prometheus-uid', + type: 'prometheus', + annotations: { + getDefaultQuery: () => ({ refId: 'Anno', expr: 'up' }), + }, + } as unknown as DataSourceApi; + + const result = getDataQueryFromAnnotationForSavedQueries(annotationWithoutQuery, datasource); + + expect(result).toEqual({ + refId: 'Anno', + expr: 'up', + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + }); + }); + + it('should use refId "Anno" as fallback when no default query exists', () => { + const annotationWithoutQuery: AnnotationQuery = { + name: 'empty annotation', + datasource: { + type: 'testdata', + uid: 'testdata-uid', + }, + enable: true, + iconColor: 'green', + }; + + const datasource = { + uid: 'testdata-uid', + type: 'testdata', + annotations: {}, + } as unknown as DataSourceApi; + + const result = getDataQueryFromAnnotationForSavedQueries(annotationWithoutQuery, datasource); + + expect(result).toEqual({ + refId: 'Anno', + datasource: { + type: 'testdata', + uid: 'testdata-uid', + }, + }); + }); + }); + + describe('updateAnnotationFromSavedQuery', () => { + it('should update annotation with clean query structure (no datasource in target)', async () => { + const annotation: AnnotationQuery = { + name: 'initialAnn', + target: { refId: 'Anno' }, + datasource: { + type: 'prometheus', + uid: 'old-prometheus', + }, + enable: true, + iconColor: 'red', + hide: false, + }; + + const replacedQuery = { + refId: 'A', + expr: 'up', + legendFormat: '__auto', + interval: '60s', + datasource: { + type: 'prometheus', + uid: 'new-prometheus', + }, + } as DataQuery; + + const result = await updateAnnotationFromSavedQuery(annotation, replacedQuery); + + // the preparation for the annotation like the mock of prometheus datasource + // removes the datasource from the target + expect(result).toEqual({ + name: 'initialAnn', + enable: true, + iconColor: 'red', + hide: false, + builtIn: undefined, + filter: undefined, + mappings: undefined, + type: undefined, + datasource: { + type: 'prometheus', + uid: 'new-prometheus', + }, + target: { + refId: 'Anno', // refId should always be 'Anno' for annotations + expr: 'up', + legendFormat: '__auto', + interval: '60s', + instant: false, // Normalized for annotation context + range: true, // Normalized for annotation context + }, + }); + }); + + it('should handle v2 dashboard annotations with query.spec', async () => { + const v2Annotation: AnnotationQuery = { + name: 'v2 annotation', + query: { + kind: 'prometheus', + spec: { + refId: 'A', + expr: 'up', + }, + }, + datasource: { + type: 'prometheus', + uid: 'original-prometheus', + }, + enable: true, + iconColor: 'blue', + hide: false, + }; + + const replacedQuery = { + refId: 'B', + expr: 'rate(http_requests_total[5m])', + legendFormat: '{{method}}', + datasource: { + type: 'prometheus', + uid: 'new-prometheus', + }, + } as DataQuery; + + const result = await updateAnnotationFromSavedQuery(v2Annotation, replacedQuery); + + expect(result).toEqual({ + name: 'v2 annotation', + enable: true, + iconColor: 'blue', + hide: false, + mappings: undefined, + builtIn: undefined, + filter: undefined, + type: undefined, + datasource: { + type: 'prometheus', + uid: 'new-prometheus', + }, + // v2 annotations maintain both target and query.spec with the new query data + target: { + refId: 'Anno', // refId should always be 'Anno' for annotations + expr: 'rate(http_requests_total[5m])', + legendFormat: '{{method}}', + interval: '', + instant: false, // Normalized for annotation context + range: true, // Normalized for annotation context + }, + query: { + kind: 'prometheus', + spec: { + refId: 'Anno', // refId should always be 'Anno' for annotations + expr: 'rate(http_requests_total[5m])', + legendFormat: '{{method}}', + interval: '', + instant: false, // Normalized for annotation context + range: true, // Normalized for annotation context + }, + }, + }); + }); + + it('should preserve all annotation-specific fields and clean old query data', async () => { + const annotationWithManyFields: AnnotationQuery = { + name: 'complex annotation', + target: { refId: 'OldRef', expr: 'old_expr' }, + datasource: { type: 'prometheus', uid: 'old-uid' }, + enable: false, + iconColor: 'yellow', + hide: true, + mappings: { title: { value: 'test' } }, + filter: { exclude: false, list: ['tag1'] }, + type: 'dashboard', + builtIn: 1, + // These should be cleaned out + oldQueryField: 'should be removed', + anotherOldField: 'also removed', + } as unknown as AnnotationQuery; + + const replacedQuery = { + refId: 'NewRef', + expr: 'new_expr', + datasource: { type: 'loki', uid: 'new-loki-uid' }, + } as DataQuery; + + const result = await updateAnnotationFromSavedQuery(annotationWithManyFields, replacedQuery); + + expect(result).toEqual({ + name: 'complex annotation', + enable: false, + hide: true, + iconColor: 'yellow', + mappings: { title: { value: 'test' } }, + filter: { exclude: false, list: ['tag1'] }, + type: 'dashboard', + builtIn: 1, + datasource: { type: 'loki', uid: 'new-loki-uid' }, + target: { + refId: 'Anno', // refId should always be 'Anno' for annotations + expr: 'new_expr', + interval: '', + instant: false, // Normalized for annotation context + range: true, // Normalized for annotation context + }, + }); + + // Ensure old query fields are not present + expect(result).not.toHaveProperty('oldQueryField'); + expect(result).not.toHaveProperty('anotherOldField'); + }); + + it('should handle cross-datasource replacement', async () => { + const prometheusAnnotation: AnnotationQuery = { + name: 'prometheus annotation', + target: { + refId: 'A', + expr: 'prometheus_query', + } as PromQuery, + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + enable: true, + iconColor: 'red', + }; + + const lokiQuery = { + refId: 'B', + expr: '{job="test"}', + datasource: { + type: 'loki', + uid: 'loki-uid', + }, + } as DataQuery; + + const result = await updateAnnotationFromSavedQuery(prometheusAnnotation, lokiQuery); + + expect(result.datasource).toEqual({ + type: 'loki', + uid: 'loki-uid', + }); + expect(result.target).toEqual({ + refId: 'Anno', // refId should always be 'Anno' for annotations + expr: '{job="test"}', + interval: '', + instant: false, // Normalized for annotation context + range: true, // Normalized for annotation context + }); + expect(result.name).toBe('prometheus annotation'); + expect(result.iconColor).toBe('red'); + }); + + it('should handle missing optional fields gracefully', async () => { + const minimalAnnotation: AnnotationQuery = { + name: 'minimal annotation', + enable: true, + iconColor: 'red', + }; + + const replacedQuery = { + refId: 'A', + expr: 'up', + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + } as DataQuery; + + const result = await updateAnnotationFromSavedQuery(minimalAnnotation, replacedQuery); + + expect(result).toEqual({ + name: 'minimal annotation', + enable: true, + hide: undefined, + iconColor: 'red', + mappings: undefined, + filter: undefined, + type: undefined, + builtIn: undefined, + datasource: { + type: 'prometheus', + uid: 'prometheus-uid', + }, + target: { + refId: 'Anno', // refId should always be 'Anno' for annotations + expr: 'up', + interval: '', + instant: false, // Normalized for annotation context + range: true, // Normalized for annotation context + }, + }); + }); + }); +}); diff --git a/public/app/features/annotations/utils/savedQueryUtils.ts b/public/app/features/annotations/utils/savedQueryUtils.ts new file mode 100644 index 00000000000..e03b59ff63b --- /dev/null +++ b/public/app/features/annotations/utils/savedQueryUtils.ts @@ -0,0 +1,130 @@ +import { AnnotationQuery, CoreApp, DataSourceApi, hasQueryExportSupport, hasQueryImportSupport } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; + +import { standardAnnotationSupport } from '../standardAnnotationSupport'; + +/** + * Converts an AnnotationQuery to DataQuery format for SavedQueryButtons. + * Supports both v1 dashboards (uses target field) and v2 dashboards (uses query.spec field). + */ +export function getDataQueryFromAnnotationForSavedQueries( + annotation: AnnotationQuery, + datasource: DataSourceApi +): DataQuery { + // For v2 dashboards, use query.spec + let querySpec = annotation.target; + if (annotation.query && annotation.query.spec) { + querySpec = annotation.query.spec; + } + + const baseQuery = { + ...datasource.annotations?.getDefaultQuery?.(), + ...(querySpec ?? { refId: 'Anno' }), + }; + + return { + ...baseQuery, + datasource: annotation.datasource, + }; +} + +/** + * Converts DataQuery back to AnnotationQuery format while preserving annotation metadata. + * Used when replacing an annotation query with a saved query. + * Supports both v1 dashboards (uses target field) and v2 dashboards (uses query.spec field). + * + * This function is async and self-contained - it returns a properly prepared annotation + * without relying on external cleanup via verifyDataSource. + */ +export async function updateAnnotationFromSavedQuery( + annotation: AnnotationQuery, + replacedQuery: DataQuery +): Promise { + // Step 1: Create clean annotation structure with only annotation-specific fields + const cleanAnnotation = { + name: annotation.name, + enable: annotation.enable, + hide: annotation.hide, + iconColor: annotation.iconColor, + mappings: annotation.mappings, + filter: annotation.filter, + type: annotation.type, + builtIn: annotation.builtIn, + datasource: replacedQuery.datasource, + }; + + // Step 2: Use datasource's export/import to normalize saved query + try { + const newDatasource = await getDataSourceSrv().get(replacedQuery.datasource); + + // Normalize saved query using export/import approach (strips context, keeps content) + // This follows the same pattern as updateQueries.ts for datasource transitions + let normalizedQuery = replacedQuery; + + // When datasource supports abstract queries, use export/import to normalize context + if (hasQueryExportSupport(newDatasource) && hasQueryImportSupport(newDatasource)) { + const abstractQueries = await newDatasource.exportToAbstractQueries([replacedQuery]); + const importedQueries = await newDatasource.importFromAbstractQueries(abstractQueries); + + if (importedQueries.length > 0) { + // Apply annotation-specific defaults to the normalized query + const annotationDefaults = { + ...newDatasource.getDefaultQuery?.(CoreApp.Dashboard), + datasource: replacedQuery.datasource, + refId: 'Anno', + }; + + normalizedQuery = { + ...replacedQuery, // Start with all original properties + ...annotationDefaults, // Apply annotation defaults for context + ...importedQueries[0], // Apply normalized core query content + refId: 'Anno', // Always use Anno refId for annotations + }; + } + } + // For datasources without export/import support, keep the query unchanged + // except for refId which should always be 'Anno' for annotations + else { + normalizedQuery = { + ...replacedQuery, + refId: 'Anno', + }; + } + + // Remove datasource property to avoid duplication in target + const { datasource, ...queryFields } = normalizedQuery; + + // Step 3: Create annotation and apply datasource-specific preparation + const tempAnnotation: AnnotationQuery = { + ...cleanAnnotation, + target: queryFields, + }; + + const processor = { ...standardAnnotationSupport, ...newDatasource.annotations }; + let preparedAnnotation: AnnotationQuery; + + if (processor.prepareAnnotation) { + // Let the datasource do final preparation/restructuring + preparedAnnotation = processor.prepareAnnotation(tempAnnotation); + } else { + preparedAnnotation = tempAnnotation; + } + + // Step 4: Handle v1 vs v2 dashboard format after preparation + if (annotation.query?.spec) { + // v2 dashboard - sync prepared target to query.spec + preparedAnnotation.query = { + ...annotation.query, + spec: { ...preparedAnnotation.target }, + }; + } + + return preparedAnnotation; + } catch (error) { + console.warn('Could not prepare annotation with new datasource:', error); + // Return structurally correct annotation even if preparation fails + const { datasource, ...queryFields } = replacedQuery; + return { ...cleanAnnotation, target: queryFields }; + } +}