From 90c0b30a654139568eb883e2ac109d80850ca596 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Sun, 11 May 2025 12:54:24 +0200 Subject: [PATCH] [release-11.6.2] Prometheus: Move to new annotation support as the old one is deprecated (#105187) 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 91561499608fc466a8fae43793cacb8485a156a7) --- .../src/annotations.test.ts | 723 ++++++++++++++++++ .../grafana-prometheus/src/annotations.ts | 133 ++++ .../components/AnnotationQueryEditor.test.tsx | 165 ++++ .../src/components/AnnotationQueryEditor.tsx | 120 +-- .../grafana-prometheus/src/datasource.test.ts | 271 ------- packages/grafana-prometheus/src/datasource.ts | 155 +--- 6 files changed, 1088 insertions(+), 479 deletions(-) create mode 100644 packages/grafana-prometheus/src/annotations.test.ts create mode 100644 packages/grafana-prometheus/src/annotations.ts create mode 100644 packages/grafana-prometheus/src/components/AnnotationQueryEditor.test.tsx diff --git a/packages/grafana-prometheus/src/annotations.test.ts b/packages/grafana-prometheus/src/annotations.test.ts new file mode 100644 index 00000000000..6e6363da1d9 --- /dev/null +++ b/packages/grafana-prometheus/src/annotations.test.ts @@ -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) => { + 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 & { 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 & { 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 & { 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 & { 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 = { + 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 & { 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; + + // Mock the implementation to match the real one + jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => { + return new Observable(); // 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; + + 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; + + 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; + + // 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; + + 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; + + 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; + + 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; + + // 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; + + 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; + + // 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, + }; +} diff --git a/packages/grafana-prometheus/src/annotations.ts b/packages/grafana-prometheus/src/annotations.ts new file mode 100644 index 00000000000..ae1151884db --- /dev/null +++ b/packages/grafana-prometheus/src/annotations.ts @@ -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 => { + return { + QueryEditor: AnnotationQueryEditor, + prepareAnnotation(json: AnnotationQuery): AnnotationQuery { + // 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, frames: DataFrame[]): Observable { + if (!frames.length) { + return new Observable(); + } + + 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); + }, + }; +}; diff --git a/packages/grafana-prometheus/src/components/AnnotationQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.test.tsx new file mode 100644 index 00000000000..0cce4331a46 --- /dev/null +++ b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.test.tsx @@ -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: () =>
Query Editor
, +})); + +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 = { + 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(); + 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(); + expect(screen.getByText('annotation data load error!')).toBeInTheDocument(); + }); + + it('displays an error message when onAnnotationChange is missing', () => { + render(); + expect(screen.getByText('annotation data load error!')).toBeInTheDocument(); + }); + + it('renders correctly with an empty annotation object', () => { + render(} />); + // 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(); + 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(); + 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(); + 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(); + 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(); + const toggle = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp); + fireEvent.click(toggle); + + expect(mockOnAnnotationChange).toHaveBeenCalledWith({ + ...mockAnnotation, + useValueForTime: true, + }); + }); +}); diff --git a/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx index 93368508d0b..bc7746f2bdf 100644 --- a/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx +++ b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx @@ -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) => 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

annotation data load error!

; + } + + 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 ( <> - { - onAnnotationChange({ - ...annotation, - expr: query.expr, - }); - }} - /> + { - 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} /> @@ -71,28 +101,18 @@ export function AnnotationQueryEditor(props: Props) { > { - 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} /> { - 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} /> @@ -104,14 +124,9 @@ export function AnnotationQueryEditor(props: Props) { > { - 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} /> @@ -122,17 +137,12 @@ export function AnnotationQueryEditor(props: Props) { } > { - onAnnotationChange({ - ...annotation, - useValueForTime: event.currentTarget.value, - }); - }} + value={annotation.useValueForTime ?? false} + onChange={(event) => handleUseValueForTimeChange(event.currentTarget.checked)} data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp} /> ); -} +}); diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts index 1e11c3b54d8..3c42ec2d0e1 100644 --- a/packages/grafana-prometheus/src/datasource.test.ts +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -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; - 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; - - 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; - 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; - 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; - 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; - 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', diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index cbd72ba2423..524ae32039c 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -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): Promise { - 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({ - 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) => { - return this.processAnnotationResponse(options, rsp.data); - }) - ) - ); - } - - processAnnotationResponse = (options: AnnotationQueryRequest, 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