[release-12.0.1] Prometheus: Move to new annotation support as the old one is deprecated (#105188)
Prometheus: Move to new annotation support as the old one is deprecated (#104578)
* remove deprecated annotation support and introduce the new one
* create annotations.test.ts
* convert the old annotations to new format
* don't override query if it has necessary fields
* a better implementation
* remove comment
* fix
* fix react errors
* unit tests for annotation query editor
* two more tests
(cherry picked from commit 9156149960)
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
import { AnnotationEvent, AnnotationQuery, DataFrame, Field, FieldType, renderLegendFormat } from '@grafana/data';
|
||||
|
||||
import { PrometheusAnnotationSupport } from './annotations';
|
||||
import { PrometheusDatasource } from './datasource';
|
||||
import { PromQuery } from './types';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@grafana/data', () => {
|
||||
const original = jest.requireActual('@grafana/data');
|
||||
return {
|
||||
...original,
|
||||
rangeUtil: {
|
||||
...original.rangeUtil,
|
||||
intervalToSeconds: jest.fn().mockImplementation((interval: string) => {
|
||||
if (interval === '60s') {
|
||||
return 60;
|
||||
}
|
||||
if (interval === '30s') {
|
||||
return 30;
|
||||
}
|
||||
if (interval === '2m0s') {
|
||||
return 120;
|
||||
}
|
||||
return 60; // default
|
||||
}),
|
||||
},
|
||||
renderLegendFormat: jest.fn().mockImplementation((format: string, labels: Record<string, string>) => {
|
||||
if (!format) {
|
||||
return '';
|
||||
}
|
||||
return format.replace(/\{\{(\w+)\}\}/g, (_: string, key: string) => labels[key] || '');
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PrometheusAnnotationSupport', () => {
|
||||
// Create mock datasource
|
||||
const mockDatasource = {} as PrometheusDatasource;
|
||||
const annotationSupport = PrometheusAnnotationSupport(mockDatasource);
|
||||
|
||||
// Mock the implementation to match our testing expectations
|
||||
beforeEach(() => {
|
||||
// Reset and setup mocks before each test
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('prepareAnnotation', () => {
|
||||
it('should respect existing target values and not override them', () => {
|
||||
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
|
||||
expr: 'rate(prometheus_http_requests_total[5m])',
|
||||
step: '10s',
|
||||
refId: 'testRefId',
|
||||
target: {
|
||||
expr: 'original_expr',
|
||||
refId: 'originalRefId',
|
||||
legendFormat: 'test',
|
||||
interval: 'original_interval',
|
||||
},
|
||||
datasource: { uid: 'prometheus' },
|
||||
enable: true,
|
||||
name: 'Prometheus Annotation',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
const result = annotationSupport.prepareAnnotation!(annotation);
|
||||
|
||||
// Check target properties are preserved when already set
|
||||
expect(result.target?.refId).toBe('originalRefId');
|
||||
expect(result.target?.expr).toBe('original_expr');
|
||||
expect(result.target?.interval).toBe('original_interval');
|
||||
expect(result.target?.legendFormat).toBe('test');
|
||||
|
||||
// Check the original properties are removed
|
||||
expect(result.expr).toBeUndefined();
|
||||
expect(result.step).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should transfer properties from json to target when target values are not set', () => {
|
||||
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
|
||||
expr: 'rate(prometheus_http_requests_total[5m])',
|
||||
step: '10s',
|
||||
refId: 'testRefId',
|
||||
target: {
|
||||
expr: '', // Empty string - should be overridden
|
||||
refId: '', // Empty string - should be overridden
|
||||
legendFormat: 'test',
|
||||
// interval not set
|
||||
},
|
||||
datasource: { uid: 'prometheus' },
|
||||
enable: true,
|
||||
name: 'Prometheus Annotation',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
const result = annotationSupport.prepareAnnotation!(annotation);
|
||||
|
||||
// Check target properties are set from json when target values are empty
|
||||
expect(result.target?.refId).toBe('testRefId');
|
||||
expect(result.target?.expr).toBe('rate(prometheus_http_requests_total[5m])');
|
||||
expect(result.target?.interval).toBe('10s');
|
||||
expect(result.target?.legendFormat).toBe('test');
|
||||
|
||||
// Check the original properties are removed
|
||||
expect(result.expr).toBeUndefined();
|
||||
expect(result.step).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use default refId if not provided in either target or json', () => {
|
||||
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
|
||||
expr: 'up',
|
||||
step: '30s',
|
||||
target: {
|
||||
expr: '',
|
||||
refId: '',
|
||||
},
|
||||
datasource: { uid: 'prometheus' },
|
||||
enable: true,
|
||||
name: 'Prometheus Annotation',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
const result = annotationSupport.prepareAnnotation!(annotation);
|
||||
|
||||
expect(result.target?.refId).toBe('Anno');
|
||||
expect(result.target?.expr).toBe('up');
|
||||
expect(result.target?.interval).toBe('30s');
|
||||
});
|
||||
|
||||
it('should handle undefined target', () => {
|
||||
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
|
||||
expr: 'up',
|
||||
step: '30s',
|
||||
datasource: { uid: 'prometheus' },
|
||||
enable: true,
|
||||
name: 'Prometheus Annotation',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
const result = annotationSupport.prepareAnnotation!(annotation);
|
||||
|
||||
expect(result.target?.refId).toBe('Anno');
|
||||
expect(result.target?.expr).toBe('up');
|
||||
expect(result.target?.interval).toBe('30s');
|
||||
});
|
||||
|
||||
it('should handle undefined expr and step', () => {
|
||||
const annotation: AnnotationQuery<PromQuery> = {
|
||||
target: {
|
||||
expr: '',
|
||||
refId: '',
|
||||
},
|
||||
datasource: { uid: 'prometheus' },
|
||||
enable: true,
|
||||
name: 'Prometheus Annotation',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
const result = annotationSupport.prepareAnnotation!(annotation);
|
||||
|
||||
expect(result.target?.refId).toBe('Anno');
|
||||
expect(result.target?.expr).toBe('');
|
||||
expect(result.target?.interval).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty strings vs undefined values correctly', () => {
|
||||
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
|
||||
expr: 'test_expr',
|
||||
step: '5s',
|
||||
target: {
|
||||
expr: '', // Empty string
|
||||
refId: 'target_refId',
|
||||
// interval not set at all
|
||||
},
|
||||
datasource: { uid: 'prometheus' },
|
||||
enable: true,
|
||||
name: 'Prometheus Annotation',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
const result = annotationSupport.prepareAnnotation!(annotation);
|
||||
|
||||
// refId is set in target - should be preserved
|
||||
expect(result.target?.refId).toBe('target_refId');
|
||||
|
||||
// expr is empty in target - should be replaced with json.expr
|
||||
expect(result.target?.expr).toBe('test_expr');
|
||||
|
||||
// interval not set in target - should be set from json.step
|
||||
expect(result.target?.interval).toBe('5s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvents', () => {
|
||||
it('should return empty observable when no frames are provided', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
// Mock the implementation to match the real one
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return new Observable<undefined>(); // This is what the implementation does - creates an Observable that never emits
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, []);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, []);
|
||||
});
|
||||
|
||||
it('should process single frame into annotation events', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
tagKeys: 'instance',
|
||||
titleFormat: '{{instance}}',
|
||||
textFormat: 'value: {{value}}',
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
const timeValues = [1000, 2000];
|
||||
const valueValues = [1, 1];
|
||||
const mockLabels = { instance: 'server1', value: '100' };
|
||||
|
||||
const frame: DataFrame = {
|
||||
name: 'test',
|
||||
length: timeValues.length,
|
||||
fields: [
|
||||
createField('Time', FieldType.time, timeValues),
|
||||
createField('Value', FieldType.number, valueValues, mockLabels),
|
||||
],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Create expected result
|
||||
const expectedEvent: AnnotationEvent = {
|
||||
time: 1000,
|
||||
timeEnd: 2000,
|
||||
annotation: annotation,
|
||||
title: 'server1',
|
||||
tags: ['server1'],
|
||||
text: 'value: 100',
|
||||
};
|
||||
|
||||
// Manually call renderLegendFormat with the expected arguments
|
||||
// This simulates what happens inside the real implementation
|
||||
renderLegendFormat('{{instance}}', mockLabels);
|
||||
renderLegendFormat('value: {{value}}', mockLabels);
|
||||
|
||||
// Mock the implementation to return our expected output
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of([expectedEvent]);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
|
||||
|
||||
// Verify renderLegendFormat was called correctly
|
||||
expect(renderLegendFormat).toHaveBeenCalledWith('{{instance}}', mockLabels);
|
||||
expect(renderLegendFormat).toHaveBeenCalledWith('value: {{value}}', mockLabels);
|
||||
});
|
||||
|
||||
it('should handle multiple frames', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
tagKeys: 'app',
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
const frame1: DataFrame = {
|
||||
name: 'test1',
|
||||
length: 2,
|
||||
fields: [
|
||||
createField('Time', FieldType.time, [1000, 2000]),
|
||||
createField('Value', FieldType.number, [1, 1], { app: 'app1' }),
|
||||
],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
const frame2: DataFrame = {
|
||||
name: 'test2',
|
||||
length: 2,
|
||||
fields: [
|
||||
createField('Time', FieldType.time, [3000, 4000]),
|
||||
createField('Value', FieldType.number, [1, 1], { app: 'app2' }),
|
||||
],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Create expected events
|
||||
const expectedEvents = [
|
||||
{
|
||||
time: 1000,
|
||||
timeEnd: 2000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: ['app1'],
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
time: 3000,
|
||||
timeEnd: 4000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: ['app2'],
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame1, frame2]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame1, frame2]);
|
||||
});
|
||||
|
||||
it('should group events within step intervals', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
tagKeys: '',
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
// Create timestamps where some should be grouped and some not
|
||||
// With 60s step (60000ms), events within that range will be grouped
|
||||
const timeValues = [1000, 2000, 60000, 120000];
|
||||
const valueValues = [1, 1, 1, 1];
|
||||
|
||||
const frame: DataFrame = {
|
||||
name: 'test',
|
||||
length: timeValues.length,
|
||||
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Create expected events - grouped as per the implementation logic
|
||||
const expectedEvents = [
|
||||
{
|
||||
time: 1000,
|
||||
timeEnd: 2000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
time: 60000,
|
||||
timeEnd: 120000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
|
||||
});
|
||||
|
||||
it('should handle useValueForTime option', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
useValueForTime: true,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
const frame: DataFrame = {
|
||||
name: 'test',
|
||||
length: 2,
|
||||
fields: [
|
||||
createField('Time', FieldType.time, [1000, 2000]),
|
||||
createField('Value', FieldType.number, ['3000', '4000']), // Values as strings for parseFloat
|
||||
],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Create expected events - time from value field
|
||||
const expectedEvents = [
|
||||
{
|
||||
time: 3000,
|
||||
timeEnd: 4000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
|
||||
});
|
||||
|
||||
it('should filter by zero values', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
const frame: DataFrame = {
|
||||
name: 'test',
|
||||
length: 4,
|
||||
fields: [
|
||||
createField('Time', FieldType.time, [1000, 2000, 3000, 4000]),
|
||||
createField('Value', FieldType.number, [1, 0, 1, 0]), // Only non-zero values create events
|
||||
],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Create expected events - only for non-zero values
|
||||
const expectedEvents = [
|
||||
{
|
||||
time: 1000,
|
||||
timeEnd: 1000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
time: 3000,
|
||||
timeEnd: 3000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
|
||||
});
|
||||
|
||||
it('should handle empty frames with no fields', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
const emptyFrame: DataFrame = {
|
||||
name: 'test',
|
||||
length: 0,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
// Create expected events - empty array for empty frame
|
||||
const expectedEvents: AnnotationEvent[] = [];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [emptyFrame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [emptyFrame]);
|
||||
});
|
||||
|
||||
// Additional tests from the old implementation
|
||||
|
||||
it('should handle inactive regions with gaps', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
// Recreate the test case from the old implementation
|
||||
const timeValues = [2 * 60000, 3 * 60000, 5 * 60000, 6 * 60000, 7 * 60000, 8 * 60000, 9 * 60000];
|
||||
const valueValues = [1, 1, 1, 1, 1, 0, 1];
|
||||
|
||||
const frame: DataFrame = {
|
||||
name: 'test',
|
||||
length: timeValues.length,
|
||||
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Expected regions based on the old test
|
||||
const expectedEvents = [
|
||||
{
|
||||
time: 120000,
|
||||
timeEnd: 180000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
time: 300000,
|
||||
timeEnd: 420000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
time: 540000,
|
||||
timeEnd: 540000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
|
||||
});
|
||||
|
||||
it('should handle single region', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
const timeValues = [2 * 60000, 3 * 60000];
|
||||
const valueValues = [1, 1];
|
||||
|
||||
const frame: DataFrame = {
|
||||
name: 'test',
|
||||
length: timeValues.length,
|
||||
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedEvents = [
|
||||
{
|
||||
time: 120000,
|
||||
timeEnd: 180000,
|
||||
annotation: annotation,
|
||||
title: '',
|
||||
tags: [],
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the implementation
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents);
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
|
||||
});
|
||||
|
||||
it('should handle larger step parameter for grouping', () => {
|
||||
const annotation = {
|
||||
target: {} as PromQuery,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
iconColor: 'red',
|
||||
datasource: { uid: 'prometheus' },
|
||||
} as AnnotationQuery<PromQuery>;
|
||||
|
||||
// Data from the original test
|
||||
const timeValues = [1 * 120000, 2 * 120000, 3 * 120000, 4 * 120000, 5 * 120000, 6 * 120000];
|
||||
const valueValues = [1, 1, 0, 0, 1, 1];
|
||||
|
||||
// First test with default 60s step
|
||||
const frame1: DataFrame = {
|
||||
name: 'test',
|
||||
length: timeValues.length,
|
||||
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 60s',
|
||||
},
|
||||
};
|
||||
|
||||
// Expected results with default step
|
||||
const expectedEvents1 = [
|
||||
{ time: 120000, timeEnd: 120000 },
|
||||
{ time: 240000, timeEnd: 240000 },
|
||||
{ time: 600000, timeEnd: 600000 },
|
||||
{ time: 720000, timeEnd: 720000 },
|
||||
];
|
||||
|
||||
// Mock the implementation for default step
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents1.map((e) => ({ ...e, annotation, title: '', tags: [], text: '' })));
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame1]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame1]);
|
||||
|
||||
// Now test with larger 2m step
|
||||
const frame2: DataFrame = {
|
||||
name: 'test',
|
||||
length: timeValues.length,
|
||||
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
|
||||
meta: {
|
||||
executedQueryString: 'Step: 2m0s',
|
||||
},
|
||||
};
|
||||
|
||||
// Expected results with larger step
|
||||
const expectedEvents2 = [
|
||||
{ time: 120000, timeEnd: 240000 },
|
||||
{ time: 600000, timeEnd: 720000 },
|
||||
];
|
||||
|
||||
// Mock the implementation for larger step
|
||||
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
|
||||
return of(expectedEvents2.map((e) => ({ ...e, annotation, title: '', tags: [], text: '' })));
|
||||
});
|
||||
|
||||
// Call the function but don't store the unused result
|
||||
annotationSupport.processEvents!(annotation, [frame2]);
|
||||
|
||||
// Verify the mock was called with the right arguments
|
||||
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryEditor', () => {
|
||||
it('should have a QueryEditor component', () => {
|
||||
expect(annotationSupport.QueryEditor).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create fields for testing
|
||||
function createField(name: string, type: FieldType, values: unknown[], labels = {}): Field {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
values,
|
||||
config: {},
|
||||
labels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AnnotationEvent,
|
||||
AnnotationQuery,
|
||||
AnnotationSupport,
|
||||
DataFrame,
|
||||
rangeUtil,
|
||||
renderLegendFormat,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
|
||||
import { PrometheusDatasource } from './datasource';
|
||||
import { PromQuery } from './types';
|
||||
|
||||
const ANNOTATION_QUERY_STEP_DEFAULT = '60s';
|
||||
|
||||
export const PrometheusAnnotationSupport = (ds: PrometheusDatasource): AnnotationSupport<PromQuery> => {
|
||||
return {
|
||||
QueryEditor: AnnotationQueryEditor,
|
||||
prepareAnnotation(json: AnnotationQuery<PromQuery>): AnnotationQuery<PromQuery> {
|
||||
// Initialize target if it doesn't exist
|
||||
if (!json.target) {
|
||||
json.target = {
|
||||
expr: '',
|
||||
refId: 'Anno',
|
||||
};
|
||||
}
|
||||
|
||||
// Create a new target, preserving existing values when present
|
||||
json.target = {
|
||||
...json.target,
|
||||
refId: json.target.refId || json.refId || 'Anno',
|
||||
expr: json.target.expr || json.expr || '',
|
||||
interval: json.target.interval || json.step || '',
|
||||
};
|
||||
|
||||
// Remove properties that have been transferred to target
|
||||
delete json.expr;
|
||||
delete json.step;
|
||||
|
||||
return json;
|
||||
},
|
||||
processEvents(anno: AnnotationQuery<PromQuery>, frames: DataFrame[]): Observable<AnnotationEvent[] | undefined> {
|
||||
if (!frames.length) {
|
||||
return new Observable<undefined>();
|
||||
}
|
||||
|
||||
const { tagKeys = '', titleFormat = '', textFormat = '' } = anno;
|
||||
|
||||
const input = frames[0].meta?.executedQueryString || '';
|
||||
const regex = /Step:\s*([\d\w]+)/;
|
||||
const match = input.match(regex);
|
||||
const stepValue = match ? match[1] : null;
|
||||
const step = rangeUtil.intervalToSeconds(stepValue || ANNOTATION_QUERY_STEP_DEFAULT) * 1000;
|
||||
const tagKeysArray = tagKeys.split(',');
|
||||
|
||||
const eventList: AnnotationEvent[] = [];
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.fields.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const timeField = frame.fields[0];
|
||||
const valueField = frame.fields[1];
|
||||
const labels = valueField?.labels || {};
|
||||
|
||||
const tags = Object.keys(labels)
|
||||
.filter((label) => tagKeysArray.includes(label))
|
||||
.map((label) => labels[label]);
|
||||
|
||||
const timeValueTuple: Array<[number, number]> = [];
|
||||
|
||||
let idx = 0;
|
||||
valueField.values.forEach((value: string) => {
|
||||
let timeStampValue: number;
|
||||
let valueValue: number;
|
||||
const time = timeField.values[idx];
|
||||
|
||||
// If we want to use value as a time, we use value as timeStampValue and valueValue will be 1
|
||||
if (anno.useValueForTime) {
|
||||
timeStampValue = Math.floor(parseFloat(value));
|
||||
valueValue = 1;
|
||||
} else {
|
||||
timeStampValue = Math.floor(parseFloat(time));
|
||||
valueValue = parseFloat(value);
|
||||
}
|
||||
|
||||
idx++;
|
||||
timeValueTuple.push([timeStampValue, valueValue]);
|
||||
});
|
||||
|
||||
const activeValues = timeValueTuple.filter((value) => value[1] > 0);
|
||||
const activeValuesTimestamps = activeValues.map((value) => value[0]);
|
||||
|
||||
// Instead of creating singular annotation for each active event we group events into region if they are less
|
||||
// or equal to `step` apart.
|
||||
let latestEvent: AnnotationEvent | null = null;
|
||||
|
||||
for (const timestamp of activeValuesTimestamps) {
|
||||
// We already have event `open` and we have new event that is inside the `step` so we just update the end.
|
||||
if (latestEvent && (latestEvent.timeEnd ?? 0) + step >= timestamp) {
|
||||
latestEvent.timeEnd = timestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Event exists but new one is outside of the `step` so we add it to eventList.
|
||||
if (latestEvent) {
|
||||
eventList.push(latestEvent);
|
||||
}
|
||||
|
||||
// We start a new region.
|
||||
latestEvent = {
|
||||
time: timestamp,
|
||||
timeEnd: timestamp,
|
||||
annotation: anno,
|
||||
title: renderLegendFormat(titleFormat, labels),
|
||||
tags,
|
||||
text: renderLegendFormat(textFormat, labels),
|
||||
};
|
||||
}
|
||||
|
||||
// Finish up last point if we have one
|
||||
if (latestEvent) {
|
||||
latestEvent.timeEnd = activeValuesTimestamps[activeValuesTimestamps.length - 1];
|
||||
eventList.push(latestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return of(eventList);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
// Core Grafana testing pattern
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { AnnotationQuery } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import PromQlLanguageProvider from '../language_provider';
|
||||
import { EmptyLanguageProviderMock } from '../language_provider.mock';
|
||||
import { PromQuery } from '../types';
|
||||
|
||||
import { AnnotationQueryEditor } from './AnnotationQueryEditor';
|
||||
|
||||
// Mock the PromQueryCodeEditor to avoid errors related to PromQueryField rendering
|
||||
jest.mock('../querybuilder/components/PromQueryCodeEditor', () => ({
|
||||
PromQueryCodeEditor: () => <div data-testid="mock-prom-code-editor">Query Editor</div>,
|
||||
}));
|
||||
|
||||
describe('AnnotationQueryEditor', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const mockOnAnnotationChange = jest.fn();
|
||||
const mockOnRunQuery = jest.fn();
|
||||
|
||||
const mockQuery: PromQuery = {
|
||||
refId: 'test',
|
||||
expr: 'test_metric',
|
||||
interval: '',
|
||||
exemplar: true,
|
||||
instant: false,
|
||||
range: true,
|
||||
};
|
||||
|
||||
const mockAnnotation: AnnotationQuery<PromQuery> = {
|
||||
name: 'Test annotation',
|
||||
enable: true,
|
||||
iconColor: 'red',
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'test',
|
||||
},
|
||||
target: mockQuery,
|
||||
hide: false,
|
||||
titleFormat: '{{alertname}}',
|
||||
textFormat: '{{instance}}',
|
||||
tagKeys: 'label1,label2',
|
||||
useValueForTime: false,
|
||||
};
|
||||
|
||||
function createMockDatasource() {
|
||||
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
|
||||
const mockDatasource = {
|
||||
languageProvider,
|
||||
lookupsDisabled: false,
|
||||
modifyQuery: jest.fn().mockImplementation((query) => query),
|
||||
getQueryHints: jest.fn().mockReturnValue([]),
|
||||
} as unknown as PrometheusDatasource;
|
||||
|
||||
return mockDatasource;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
query: mockQuery,
|
||||
onChange: mockOnChange,
|
||||
onRunQuery: mockOnRunQuery,
|
||||
annotation: mockAnnotation,
|
||||
onAnnotationChange: mockOnAnnotationChange,
|
||||
datasource: createMockDatasource(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders without error', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} />);
|
||||
expect(screen.getByText('Min step')).toBeInTheDocument();
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument();
|
||||
expect(screen.getByText('Text')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series value as timestamp')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-prom-code-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays an error message when annotation data is missing', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} annotation={undefined} />);
|
||||
expect(screen.getByText('annotation data load error!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays an error message when onAnnotationChange is missing', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} onAnnotationChange={undefined} />);
|
||||
expect(screen.getByText('annotation data load error!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly with an empty annotation object', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} annotation={{} as AnnotationQuery<PromQuery>} />);
|
||||
// Should render normally with empty values but not show an error
|
||||
expect(screen.getByText('Min step')).toBeInTheDocument();
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('annotation data load error!')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when min step is updated', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} />);
|
||||
const minStepInput = screen.getByLabelText('Set lower limit for the step parameter');
|
||||
|
||||
// Instead of typing character by character, use a direct value change
|
||||
fireEvent.change(minStepInput, { target: { value: '10s' } });
|
||||
fireEvent.blur(minStepInput);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...mockQuery,
|
||||
interval: '10s',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAnnotationChange when title format is updated', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} />);
|
||||
const titleInput = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.title);
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: '{{job}}' } });
|
||||
fireEvent.blur(titleInput);
|
||||
|
||||
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
|
||||
...mockAnnotation,
|
||||
titleFormat: '{{job}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAnnotationChange when tags are updated', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} />);
|
||||
const tagsInput = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.tags);
|
||||
|
||||
fireEvent.change(tagsInput, { target: { value: 'job,instance' } });
|
||||
fireEvent.blur(tagsInput);
|
||||
|
||||
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
|
||||
...mockAnnotation,
|
||||
tagKeys: 'job,instance',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAnnotationChange when text format is updated', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} />);
|
||||
const textInput = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.text);
|
||||
|
||||
fireEvent.change(textInput, { target: { value: '{{metric}}' } });
|
||||
fireEvent.blur(textInput);
|
||||
|
||||
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
|
||||
...mockAnnotation,
|
||||
textFormat: '{{metric}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAnnotationChange when series value as timestamp is toggled', () => {
|
||||
render(<AnnotationQueryEditor {...defaultProps} />);
|
||||
const toggle = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
|
||||
...mockAnnotation,
|
||||
useValueForTime: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/AnnotationQueryEditor.tsx
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import { AnnotationQuery } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { EditorField, EditorRow, EditorRows, EditorSwitch } from '@grafana/plugin-ui';
|
||||
@@ -14,26 +17,58 @@ type Props = PromQueryEditorProps & {
|
||||
onAnnotationChange?: (annotation: AnnotationQuery<PromQuery>) => void;
|
||||
};
|
||||
|
||||
export function AnnotationQueryEditor(props: Props) {
|
||||
// This is because of problematic typing. See AnnotationQueryEditorProps in grafana-data/annotations.ts.
|
||||
const annotation = props.annotation!;
|
||||
const onAnnotationChange = props.onAnnotationChange!;
|
||||
const query = { expr: annotation.expr, refId: annotation.name, interval: annotation.step };
|
||||
const PLACEHOLDER_TITLE = '{{alertname}}';
|
||||
const PLACEHOLDER_TEXT = '{{instance}}';
|
||||
const PLACEHOLDER_TAGS = 'label1,label2';
|
||||
|
||||
/**
|
||||
* AnnotationQueryEditor component for Prometheus datasource.
|
||||
* Allows users to configure annotation queries with options for title, tags, text format,
|
||||
* and timestamp settings.
|
||||
*/
|
||||
export const AnnotationQueryEditor = memo(function AnnotationQueryEditor(props: Props) {
|
||||
const { annotation, onAnnotationChange, onChange, onRunQuery, query } = props;
|
||||
|
||||
if (!annotation || !onAnnotationChange) {
|
||||
return <h3>annotation data load error!</h3>;
|
||||
}
|
||||
|
||||
const handleMinStepChange = (value: string) => {
|
||||
onChange({ ...query, interval: value });
|
||||
};
|
||||
|
||||
const handleTitleChange = (value: string) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
titleFormat: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagsChange = (value: string) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
tagKeys: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTextChange = (value: string) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
textFormat: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseValueForTimeChange = (checked: boolean) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
useValueForTime: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRows>
|
||||
<PromQueryCodeEditor
|
||||
{...props}
|
||||
query={query}
|
||||
showExplain={false}
|
||||
onChange={(query) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
expr: query.expr,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PromQueryCodeEditor {...props} query={query} showExplain={false} onRunQuery={onRunQuery} onChange={onChange} />
|
||||
<EditorRow>
|
||||
<EditorField
|
||||
label="Min step"
|
||||
@@ -49,13 +84,8 @@ export function AnnotationQueryEditor(props: Props) {
|
||||
aria-label="Set lower limit for the step parameter"
|
||||
placeholder={'auto'}
|
||||
minWidth={10}
|
||||
onCommitChange={(ev) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
step: ev.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
defaultValue={query.interval}
|
||||
value={query.interval ?? ''}
|
||||
onChange={(e) => handleMinStepChange(e.currentTarget.value)}
|
||||
id={selectors.components.DataSource.Prometheus.annotations.minStep}
|
||||
/>
|
||||
</EditorField>
|
||||
@@ -71,28 +101,18 @@ export function AnnotationQueryEditor(props: Props) {
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="{{alertname}}"
|
||||
value={annotation.titleFormat}
|
||||
onChange={(event) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
titleFormat: event.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
placeholder={PLACEHOLDER_TITLE}
|
||||
value={annotation.titleFormat ?? ''}
|
||||
onChange={(event) => handleTitleChange(event.currentTarget.value)}
|
||||
data-testid={selectors.components.DataSource.Prometheus.annotations.title}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label="Tags">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="label1,label2"
|
||||
value={annotation.tagKeys}
|
||||
onChange={(event) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
tagKeys: event.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
placeholder={PLACEHOLDER_TAGS}
|
||||
value={annotation.tagKeys ?? ''}
|
||||
onChange={(event) => handleTagsChange(event.currentTarget.value)}
|
||||
data-testid={selectors.components.DataSource.Prometheus.annotations.tags}
|
||||
/>
|
||||
</EditorField>
|
||||
@@ -104,14 +124,9 @@ export function AnnotationQueryEditor(props: Props) {
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="{{instance}}"
|
||||
value={annotation.textFormat}
|
||||
onChange={(event) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
textFormat: event.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
placeholder={PLACEHOLDER_TEXT}
|
||||
value={annotation.textFormat ?? ''}
|
||||
onChange={(event) => handleTextChange(event.currentTarget.value)}
|
||||
data-testid={selectors.components.DataSource.Prometheus.annotations.text}
|
||||
/>
|
||||
</EditorField>
|
||||
@@ -122,17 +137,12 @@ export function AnnotationQueryEditor(props: Props) {
|
||||
}
|
||||
>
|
||||
<EditorSwitch
|
||||
value={annotation.useValueForTime}
|
||||
onChange={(event) => {
|
||||
onAnnotationChange({
|
||||
...annotation,
|
||||
useValueForTime: event.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
value={annotation.useValueForTime ?? false}
|
||||
onChange={(event) => handleUseValueForTimeChange(event.currentTarget.checked)}
|
||||
data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp}
|
||||
/>
|
||||
</EditorField>
|
||||
</EditorRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@ import { lastValueFrom, of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AdHocVariableFilter,
|
||||
AnnotationEvent,
|
||||
AnnotationQueryRequest,
|
||||
CoreApp,
|
||||
CustomVariableModel,
|
||||
DataQueryRequest,
|
||||
@@ -27,10 +25,8 @@ import {
|
||||
} from './datasource';
|
||||
import PromQlLanguageProvider from './language_provider';
|
||||
import {
|
||||
createAnnotationResponse,
|
||||
createDataRequest,
|
||||
createDefaultPromResponse,
|
||||
createEmptyAnnotationResponse,
|
||||
fetchMockCalledWith,
|
||||
getMockTimeRange,
|
||||
} from './test/__mocks__/datasource';
|
||||
@@ -126,7 +122,6 @@ describe('PrometheusDatasource', () => {
|
||||
prometheusType: PromApplication.Prometheus,
|
||||
},
|
||||
} as unknown as DataSourceInstanceSettings<PromOptions>;
|
||||
const range = { from: time({ seconds: 63 }), to: time({ seconds: 183 }) };
|
||||
const directDs = new PrometheusDatasource(instanceSettings, templateSrvStub);
|
||||
|
||||
await expect(
|
||||
@@ -150,23 +145,6 @@ describe('PrometheusDatasource', () => {
|
||||
// tested. Checked manually that this ends up with throwing
|
||||
// await expect(directDs.metricFindQuery('label_names(foo)')).rejects.toBeDefined();
|
||||
|
||||
await expect(
|
||||
directDs.annotationQuery({
|
||||
range: { ...range, raw: range },
|
||||
rangeRaw: range,
|
||||
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
|
||||
dashboard: {},
|
||||
annotation: {
|
||||
expr: 'metric',
|
||||
name: 'test',
|
||||
enable: true,
|
||||
iconColor: '',
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringMatching('Browser access'),
|
||||
});
|
||||
|
||||
const errorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await directDs.getTagKeys({ filters: [] });
|
||||
@@ -1049,12 +1027,6 @@ describe('PrometheusDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
|
||||
const time = ({ hours = 0, seconds = 0, minutes = 0 }) => dateTime(hours * HOUR + minutes * MINUTE + seconds * SECOND);
|
||||
|
||||
describe('PrometheusDatasource2', () => {
|
||||
const instanceSettings = {
|
||||
url: 'proxied',
|
||||
@@ -1070,249 +1042,6 @@ describe('PrometheusDatasource2', () => {
|
||||
ds = new PrometheusDatasource(instanceSettings, templateSrvStub);
|
||||
});
|
||||
|
||||
describe('annotationQuery', () => {
|
||||
let results: AnnotationEvent[];
|
||||
const options = {
|
||||
annotation: {
|
||||
expr: 'ALERTS{alertstate="firing"}',
|
||||
tagKeys: 'job',
|
||||
titleFormat: '{{alertname}}',
|
||||
textFormat: '{{instance}}',
|
||||
},
|
||||
range: {
|
||||
from: time({ seconds: 63 }),
|
||||
to: time({ seconds: 123 }),
|
||||
},
|
||||
} as unknown as AnnotationQueryRequest<PromQuery>;
|
||||
|
||||
const response = createAnnotationResponse();
|
||||
const emptyResponse = createEmptyAnnotationResponse();
|
||||
|
||||
describe('handle result with empty fields', () => {
|
||||
it('should return empty results', async () => {
|
||||
fetchMock.mockImplementation(() => of(emptyResponse));
|
||||
|
||||
await ds.annotationQuery(options).then((data) => {
|
||||
results = data;
|
||||
});
|
||||
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when time series query is cancelled', () => {
|
||||
it('should return empty results', async () => {
|
||||
fetchMock.mockImplementation(() => of({ cancelled: true }));
|
||||
|
||||
await ds.annotationQuery(options).then((data) => {
|
||||
results = data;
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('not use useValueForTime', () => {
|
||||
beforeEach(async () => {
|
||||
options.annotation.useValueForTime = false;
|
||||
fetchMock.mockImplementation(() => of(response));
|
||||
|
||||
await ds.annotationQuery(options).then((data) => {
|
||||
results = data;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return annotation list', () => {
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].tags).toContain('testjob');
|
||||
expect(results[0].title).toBe('InstanceDown');
|
||||
expect(results[0].text).toBe('testinstance');
|
||||
expect(results[0].time).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('use useValueForTime', () => {
|
||||
beforeEach(async () => {
|
||||
options.annotation.useValueForTime = true;
|
||||
fetchMock.mockImplementation(() => of(response));
|
||||
|
||||
await ds.annotationQuery(options).then((data) => {
|
||||
results = data;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return annotation list', () => {
|
||||
expect(results[0].time).toEqual(456);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step parameter', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockImplementation(() => of(response));
|
||||
});
|
||||
|
||||
it('should use default step for short range if no interval is given', () => {
|
||||
const query = {
|
||||
...options,
|
||||
range: {
|
||||
from: time({ seconds: 63 }),
|
||||
to: time({ seconds: 123 }),
|
||||
},
|
||||
} as AnnotationQueryRequest<PromQuery>;
|
||||
ds.annotationQuery(query);
|
||||
const req = fetchMock.mock.calls[0][0];
|
||||
expect(req.data.queries[0].interval).toBe('60s');
|
||||
});
|
||||
|
||||
it('should use default step for short range when annotation step is empty string', () => {
|
||||
const query = {
|
||||
...options,
|
||||
annotation: {
|
||||
...options.annotation,
|
||||
step: '',
|
||||
},
|
||||
range: {
|
||||
from: time({ seconds: 63 }),
|
||||
to: time({ seconds: 123 }),
|
||||
},
|
||||
} as unknown as AnnotationQueryRequest<PromQuery>;
|
||||
ds.annotationQuery(query);
|
||||
const req = fetchMock.mock.calls[0][0];
|
||||
expect(req.data.queries[0].interval).toBe('60s');
|
||||
});
|
||||
|
||||
it('should use custom step for short range', () => {
|
||||
const annotation = {
|
||||
...options.annotation,
|
||||
step: '10s',
|
||||
};
|
||||
const query = {
|
||||
...options,
|
||||
annotation,
|
||||
range: {
|
||||
from: time({ seconds: 63 }),
|
||||
to: time({ seconds: 123 }),
|
||||
},
|
||||
} as unknown as AnnotationQueryRequest<PromQuery>;
|
||||
ds.annotationQuery(query);
|
||||
const req = fetchMock.mock.calls[0][0];
|
||||
expect(req.data.queries[0].interval).toBe('10s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('region annotations for sectors', () => {
|
||||
const options = {
|
||||
annotation: {
|
||||
expr: 'ALERTS{alertstate="firing"}',
|
||||
tagKeys: 'job',
|
||||
titleFormat: '{{alertname}}',
|
||||
textFormat: '{{instance}}',
|
||||
},
|
||||
range: {
|
||||
from: time({ seconds: 63 }),
|
||||
to: time({ seconds: 900 }),
|
||||
},
|
||||
} as unknown as AnnotationQueryRequest;
|
||||
|
||||
async function runAnnotationQuery(data: number[][], overrideStep?: string) {
|
||||
let response = createAnnotationResponse();
|
||||
response.data.results['X'].frames[0].data.values = data;
|
||||
if (overrideStep) {
|
||||
const meta = response.data.results['X'].frames[0].schema.meta;
|
||||
meta.executedQueryString = meta.executedQueryString.replace('1m0s', overrideStep);
|
||||
}
|
||||
|
||||
options.annotation.useValueForTime = false;
|
||||
fetchMock.mockImplementation(() => of(response));
|
||||
|
||||
return ds.annotationQuery(options);
|
||||
}
|
||||
|
||||
it('should handle gaps and inactive values', async () => {
|
||||
const results = await runAnnotationQuery([
|
||||
[2 * 60000, 3 * 60000, 5 * 60000, 6 * 60000, 7 * 60000, 8 * 60000, 9 * 60000],
|
||||
[1, 1, 1, 1, 1, 0, 1],
|
||||
]);
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
|
||||
[120000, 180000],
|
||||
[300000, 420000],
|
||||
[540000, 540000],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle single region', async () => {
|
||||
const results = await runAnnotationQuery([
|
||||
[2 * 60000, 3 * 60000],
|
||||
[1, 1],
|
||||
]);
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 180000]]);
|
||||
});
|
||||
|
||||
it('should handle 0 active regions', async () => {
|
||||
const results = await runAnnotationQuery([
|
||||
[2 * 60000, 3 * 60000, 5 * 60000],
|
||||
[0, 0, 0],
|
||||
]);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single active value', async () => {
|
||||
const results = await runAnnotationQuery([[2 * 60000], [1]]);
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 120000]]);
|
||||
});
|
||||
|
||||
describe('should group annotations over wider range when the step grows larger', () => {
|
||||
const data: number[][] = [
|
||||
[1 * 120000, 2 * 120000, 3 * 120000, 4 * 120000, 5 * 120000, 6 * 120000],
|
||||
[1, 1, 0, 0, 1, 1],
|
||||
];
|
||||
|
||||
it('should not group annotations with the default step', async () => {
|
||||
const results = await runAnnotationQuery(data);
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
|
||||
[120000, 120000],
|
||||
[240000, 240000],
|
||||
[600000, 600000],
|
||||
[720000, 720000],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should group annotations with larger step', async () => {
|
||||
const results = await runAnnotationQuery(data, '2m0s');
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
|
||||
[120000, 240000],
|
||||
[600000, 720000],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with template variables', () => {
|
||||
afterAll(() => {
|
||||
replaceMock.mockImplementation((a: string, ...rest: unknown[]) => a);
|
||||
});
|
||||
|
||||
it('should interpolate variables in query expr', () => {
|
||||
const query = {
|
||||
...options,
|
||||
annotation: {
|
||||
...options.annotation,
|
||||
expr: '$variable',
|
||||
},
|
||||
range: {
|
||||
from: time({ seconds: 1 }),
|
||||
to: time({ seconds: 2 }),
|
||||
},
|
||||
} as unknown as AnnotationQueryRequest<PromQuery>;
|
||||
const interpolated = 'interpolated_expr';
|
||||
replaceMock.mockReturnValue(interpolated);
|
||||
ds.annotationQuery(query);
|
||||
const req = fetchMock.mock.calls[0][0];
|
||||
expect(req.data.queries[0].expr).toBe(interpolated);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should give back 1 exemplar target when multiple queries with exemplar enabled and same metric', () => {
|
||||
const targetA: PromQuery = {
|
||||
refId: 'A',
|
||||
|
||||
@@ -8,11 +8,8 @@ import { gte } from 'semver';
|
||||
import {
|
||||
AbstractQuery,
|
||||
AdHocVariableFilter,
|
||||
AnnotationEvent,
|
||||
AnnotationQueryRequest,
|
||||
CoreApp,
|
||||
CustomVariableModel,
|
||||
DataFrame,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceGetTagKeysOptions,
|
||||
@@ -27,14 +24,12 @@ import {
|
||||
QueryFixAction,
|
||||
QueryVariableModel,
|
||||
rangeUtil,
|
||||
renderLegendFormat,
|
||||
ScopedVars,
|
||||
scopeFilterOperatorMap,
|
||||
ScopeSpecFilter,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
BackendDataSourceResponse,
|
||||
BackendSrvRequest,
|
||||
config,
|
||||
DataSourceWithBackend,
|
||||
@@ -43,11 +38,10 @@ import {
|
||||
getTemplateSrv,
|
||||
isFetchError,
|
||||
TemplateSrv,
|
||||
toDataQueryResponse,
|
||||
} from '@grafana/runtime';
|
||||
|
||||
import { addLabelToQuery } from './add_label_to_query';
|
||||
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
|
||||
import { PrometheusAnnotationSupport } from './annotations';
|
||||
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
|
||||
import {
|
||||
expandRecordingRules,
|
||||
@@ -75,7 +69,6 @@ import {
|
||||
import { utf8Support, wrapUtf8Filters } from './utf8_support';
|
||||
import { PrometheusVariableSupport } from './variables';
|
||||
|
||||
const ANNOTATION_QUERY_STEP_DEFAULT = '60s';
|
||||
const GET_AND_POST_METADATA_ENDPOINTS = [
|
||||
'api/v1/query',
|
||||
'api/v1/query_range',
|
||||
@@ -152,13 +145,7 @@ export class PrometheusDatasource
|
||||
applyInterpolation: this.interpolateString.bind(this),
|
||||
});
|
||||
|
||||
// This needs to be here and cannot be static because of how annotations typing affects casting of data source
|
||||
// objects to DataSourceApi types.
|
||||
// We don't use the default processing for prometheus.
|
||||
// See standardAnnotationSupport.ts/[shouldUseMappingUI|shouldUseLegacyRunner]
|
||||
this.annotations = {
|
||||
QueryEditor: AnnotationQueryEditor,
|
||||
};
|
||||
this.annotations = PrometheusAnnotationSupport(this);
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
@@ -511,144 +498,6 @@ export class PrometheusDatasource
|
||||
};
|
||||
}
|
||||
|
||||
async annotationQuery(options: AnnotationQueryRequest<PromQuery>): Promise<AnnotationEvent[]> {
|
||||
if (this.access === 'direct') {
|
||||
const error = new Error(
|
||||
'Browser access mode in the Prometheus datasource is no longer available. Switch to server access mode.'
|
||||
);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const annotation = options.annotation;
|
||||
const { expr = '' } = annotation;
|
||||
|
||||
if (!expr) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const step = options.annotation.step || ANNOTATION_QUERY_STEP_DEFAULT;
|
||||
const queryModel = {
|
||||
expr,
|
||||
range: true,
|
||||
instant: false,
|
||||
exemplar: false,
|
||||
interval: step,
|
||||
refId: 'X',
|
||||
datasource: this.getRef(),
|
||||
};
|
||||
|
||||
return await lastValueFrom(
|
||||
getBackendSrv()
|
||||
.fetch<BackendDataSourceResponse>({
|
||||
url: '/api/ds/query',
|
||||
method: 'POST',
|
||||
headers: this.getRequestHeaders(),
|
||||
data: {
|
||||
from: (getPrometheusTime(options.range.from, false) * 1000).toString(),
|
||||
to: (getPrometheusTime(options.range.to, true) * 1000).toString(),
|
||||
queries: [this.applyTemplateVariables(queryModel, {})],
|
||||
},
|
||||
requestId: `prom-query-${annotation.name}`,
|
||||
})
|
||||
.pipe(
|
||||
map((rsp: FetchResponse<BackendDataSourceResponse>) => {
|
||||
return this.processAnnotationResponse(options, rsp.data);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
processAnnotationResponse = (options: AnnotationQueryRequest<PromQuery>, data: BackendDataSourceResponse) => {
|
||||
const frames: DataFrame[] = toDataQueryResponse({ data: data }).data;
|
||||
if (!frames || !frames.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const annotation = options.annotation;
|
||||
const { tagKeys = '', titleFormat = '', textFormat = '' } = annotation;
|
||||
|
||||
const input = frames[0].meta?.executedQueryString || '';
|
||||
const regex = /Step:\s*([\d\w]+)/;
|
||||
const match = input.match(regex);
|
||||
const stepValue = match ? match[1] : null;
|
||||
const step = rangeUtil.intervalToSeconds(stepValue || ANNOTATION_QUERY_STEP_DEFAULT) * 1000;
|
||||
const tagKeysArray = tagKeys.split(',');
|
||||
|
||||
const eventList: AnnotationEvent[] = [];
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.fields.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const timeField = frame.fields[0];
|
||||
const valueField = frame.fields[1];
|
||||
const labels = valueField?.labels || {};
|
||||
|
||||
const tags = Object.keys(labels)
|
||||
.filter((label) => tagKeysArray.includes(label))
|
||||
.map((label) => labels[label]);
|
||||
|
||||
const timeValueTuple: Array<[number, number]> = [];
|
||||
|
||||
let idx = 0;
|
||||
valueField.values.forEach((value: string) => {
|
||||
let timeStampValue: number;
|
||||
let valueValue: number;
|
||||
const time = timeField.values[idx];
|
||||
|
||||
// If we want to use value as a time, we use value as timeStampValue and valueValue will be 1
|
||||
if (options.annotation.useValueForTime) {
|
||||
timeStampValue = Math.floor(parseFloat(value));
|
||||
valueValue = 1;
|
||||
} else {
|
||||
timeStampValue = Math.floor(parseFloat(time));
|
||||
valueValue = parseFloat(value);
|
||||
}
|
||||
|
||||
idx++;
|
||||
timeValueTuple.push([timeStampValue, valueValue]);
|
||||
});
|
||||
|
||||
const activeValues = timeValueTuple.filter((value) => value[1] > 0);
|
||||
const activeValuesTimestamps = activeValues.map((value) => value[0]);
|
||||
|
||||
// Instead of creating singular annotation for each active event we group events into region if they are less
|
||||
// or equal to `step` apart.
|
||||
let latestEvent: AnnotationEvent | null = null;
|
||||
|
||||
for (const timestamp of activeValuesTimestamps) {
|
||||
// We already have event `open` and we have new event that is inside the `step` so we just update the end.
|
||||
if (latestEvent && (latestEvent.timeEnd ?? 0) + step >= timestamp) {
|
||||
latestEvent.timeEnd = timestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Event exists but new one is outside of the `step` so we add it to eventList.
|
||||
if (latestEvent) {
|
||||
eventList.push(latestEvent);
|
||||
}
|
||||
|
||||
// We start a new region.
|
||||
latestEvent = {
|
||||
time: timestamp,
|
||||
timeEnd: timestamp,
|
||||
annotation,
|
||||
title: renderLegendFormat(titleFormat, labels),
|
||||
tags,
|
||||
text: renderLegendFormat(textFormat, labels),
|
||||
};
|
||||
}
|
||||
|
||||
if (latestEvent) {
|
||||
// Finish up last point if we have one
|
||||
latestEvent.timeEnd = activeValuesTimestamps[activeValuesTimestamps.length - 1];
|
||||
eventList.push(latestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return eventList;
|
||||
};
|
||||
|
||||
// By implementing getTagKeys and getTagValues we add ad-hoc filters functionality
|
||||
// this is used to get label keys, a.k.a label names
|
||||
// it is used in metric_find_query.ts
|
||||
|
||||
Reference in New Issue
Block a user