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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user