mirror of https://github.com/grafana/grafana
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 testspull/105106/head^2
parent
7042e73343
commit
9156149960
@ -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, |
||||
}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue