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
This commit is contained in:
Alexa Vargas
2025-09-23 12:21:41 +02:00
committed by GitHub
parent 3eab510c1b
commit 4ebacdbdc2
5 changed files with 936 additions and 13 deletions
@@ -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<DataQuery>;
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 (
<Stack direction="column" gap={1}>
<Stack justifyContent="flex-end">{savedQueryButtons}</Stack>
{children}
</Stack>
);
}
@@ -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(() => <div>Editor</div>),
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(() => <div>Editor</div>),
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(() => <div>Editor</div>),
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(() => <div>Editor</div>),
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<StandardAnnotationQueryEditor>();
const { rerender } = render(
<StandardAnnotationQueryEditor
ref={componentRef}
annotation={originalAnnotation}
datasource={
{
uid: 'prometheus-uid',
type: 'prometheus',
annotations: {
QueryEditor: jest.fn(() => <div>Editor</div>),
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(
<StandardAnnotationQueryEditor
ref={componentRef}
annotation={newAnnotation} // New annotation object reference
datasource={
{
uid: 'loki-uid', // Different datasource to match the new annotation
type: 'loki',
annotations: {
QueryEditor: jest.fn(() => <div>Editor</div>),
prepareAnnotation: mockPrepareAnnotation,
},
} as unknown as DataSourceApi
}
datasourceInstanceSettings={{} as DataSourceInstanceSettings}
onChange={mockOnChange}
/>
);
});
expect(mockPrepareAnnotation).not.toHaveBeenCalled();
});
});
});
@@ -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<Props, State> {
@@ -48,16 +51,31 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
}
}
/**
* verifyDataSource() prepares the annotation and provides immediate query feedback:
* 1. Applies datasource-specific preparation (e.g., Prometheus moves expr to target field)
* 2. Updates annotation if preparation made changes
* 3. Runs query to show immediate results in the UI
*/
verifyDataSource() {
const { datasource, annotation } = this.props;
// Handle any migration issues
// Skip verification if we just did a saved query replacement to avoid double preparation
if (this.state.skipNextVerification) {
this.setState({ skipNextVerification: false });
this.onRunQuery();
return;
}
// Always run prepareAnnotation to ensure proper query structure
// This is essential for datasources like Prometheus that need to format queries correctly
const processor = {
...standardAnnotationSupport,
...datasource.annotations,
};
const fixed = processor.prepareAnnotation!(annotation);
// if datasource prepared annotation returns a different annotation(e.g., prometheus before had expr in the root level now it's saved in 'target'), update the annotation with that one
if (fixed !== annotation) {
this.props.onChange(fixed);
} else {
@@ -232,6 +250,21 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
});
};
onQueryReplace = async (replacedQuery: DataQuery) => {
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<Props,
return (
<>
<DataSourcePluginContextProvider instanceSettings={datasourceInstanceSettings}>
<QueryEditor
key={datasource?.name}
query={query}
<AnnotationQueryEditorActionsWrapper
annotation={annotation}
datasource={datasource}
onChange={this.onQueryChange}
onRunQuery={this.onRunQuery}
data={response?.panelData}
range={getTimeSrv().timeRange()}
annotation={editorAnnotation}
onAnnotationChange={this.onAnnotationChange}
/>
datasourceInstanceSettings={datasourceInstanceSettings}
onQueryReplace={this.onQueryReplace}
>
<QueryEditor
key={datasource?.name}
query={query}
datasource={datasource}
onChange={this.onQueryChange}
onRunQuery={this.onRunQuery}
data={response?.panelData}
range={getTimeSrv().timeRange()}
annotation={editorAnnotation}
onAnnotationChange={this.onAnnotationChange}
/>
</AnnotationQueryEditorActionsWrapper>
</DataSourcePluginContextProvider>
{shouldUseMappingUI(datasource) && (
<>
@@ -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<PromQuery> => ({
refId: 'A',
expr: '',
range: true,
instant: false,
})
),
// Mock export/import methods for query normalization
exportToAbstractQueries: jest.fn(async (queries: DataQuery[]): Promise<AbstractQuery[]> => {
// 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<PromQuery[]> => {
// 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
},
});
});
});
});
@@ -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<AnnotationQuery> {
// 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 };
}
}