From 0a95d493e348d267d913c68206f4547f06cfacb6 Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Thu, 19 May 2022 13:52:52 -0700 Subject: [PATCH] Cloud Monitoring: Use new annotation API (#49026) * remove angular code * format annotation on backend * format time with time type instead of string * update annotation query tests * update get alignment data function * update annotation query editor * add annotation query editor test * update struct * add tests * remove extracted function * remove non-null assertion * remove stray commented out console.log * fix jest haste map warning * add alignment period * add AnnotationMetricQuery type --- pkg/tsdb/cloudmonitoring/annotation_query.go | 43 ++-- .../cloudmonitoring/annotation_query_test.go | 27 ++- .../cloudmonitoring/time_series_filter.go | 33 +-- pkg/tsdb/cloudmonitoring/time_series_query.go | 14 +- public/app/angular/angular_wrappers.ts | 7 - .../__mocks__/cloudMonitoringDatasource.ts | 13 ++ .../__mocks__/cloudMonitoringQuery.ts | 21 ++ .../annotationSupport.test.ts | 114 ++++++++++ .../cloud-monitoring/annotationSupport.ts | 78 +++++++ .../annotations_query_ctrl.ts | 18 -- .../components/AnnotationQueryEditor.test.tsx | 54 +++++ .../components/AnnotationQueryEditor.tsx | 201 +++++++----------- .../datasource/cloud-monitoring/constants.ts | 14 ++ .../datasource/cloud-monitoring/datasource.ts | 64 +----- .../cloud-monitoring/functions.test.ts | 4 +- .../datasource/cloud-monitoring/module.ts | 2 - .../partials/annotations.editor.html | 6 - .../datasource/cloud-monitoring/types.ts | 10 +- 18 files changed, 455 insertions(+), 268 deletions(-) create mode 100644 public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringDatasource.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringQuery.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/annotationSupport.test.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/annotationSupport.ts delete mode 100644 public/app/plugins/datasource/cloud-monitoring/annotations_query_ctrl.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.test.tsx delete mode 100644 public/app/plugins/datasource/cloud-monitoring/partials/annotations.editor.html diff --git a/pkg/tsdb/cloudmonitoring/annotation_query.go b/pkg/tsdb/cloudmonitoring/annotation_query.go index 66f2471f865..fa41080b6ce 100644 --- a/pkg/tsdb/cloudmonitoring/annotation_query.go +++ b/pkg/tsdb/cloudmonitoring/annotation_query.go @@ -4,11 +4,19 @@ import ( "context" "encoding/json" "strings" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" ) +type annotationEvent struct { + Title string + Time time.Time + Tags string + Text string +} + func (s *Service) executeAnnotationQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo) ( *backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() @@ -24,8 +32,10 @@ func (s *Service) executeAnnotationQuery(ctx context.Context, req *backend.Query } mq := struct { - Title string `json:"title"` - Text string `json:"text"` + MetricQuery struct { + Title string `json:"title"` + Text string `json:"text"` + } `json:"metricQuery"` }{} firstQuery := req.Queries[0] @@ -33,32 +43,23 @@ func (s *Service) executeAnnotationQuery(ctx context.Context, req *backend.Query if err != nil { return resp, nil } - err = queries[0].parseToAnnotations(queryRes, dr, mq.Title, mq.Text) + err = queries[0].parseToAnnotations(queryRes, dr, mq.MetricQuery.Title, mq.MetricQuery.Text) resp.Responses[firstQuery.RefID] = *queryRes return resp, err } -func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) transformAnnotationToFrame(annotations []map[string]string, result *backend.DataResponse) { - frames := data.Frames{} +func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) transformAnnotationToFrame(annotations []*annotationEvent, result *backend.DataResponse) { + frame := data.NewFrame(timeSeriesQuery.RefID, + data.NewField("time", nil, []time.Time{}), + data.NewField("title", nil, []string{}), + data.NewField("tags", nil, []string{}), + data.NewField("text", nil, []string{}), + ) for _, a := range annotations { - frame := &data.Frame{ - RefID: timeSeriesQuery.getRefID(), - Fields: []*data.Field{ - data.NewField("time", nil, a["time"]), - data.NewField("title", nil, a["title"]), - data.NewField("tags", nil, a["tags"]), - data.NewField("text", nil, a["text"]), - }, - Meta: &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": len(a), - }, - }, - } - frames = append(frames, frame) + frame.AppendRow(a.Time, a.Title, a.Tags, a.Text) } - result.Frames = frames + result.Frames = append(result.Frames, frame) slog.Info("anno", "len", len(annotations)) } diff --git a/pkg/tsdb/cloudmonitoring/annotation_query_test.go b/pkg/tsdb/cloudmonitoring/annotation_query_test.go index 1762663945c..e502067ce10 100644 --- a/pkg/tsdb/cloudmonitoring/annotation_query_test.go +++ b/pkg/tsdb/cloudmonitoring/annotation_query_test.go @@ -20,10 +20,15 @@ func TestExecutor_parseToAnnotations(t *testing.T) { "atext {{resource.label.zone}}") require.NoError(t, err) - require.Len(t, res.Frames, 3) + require.Len(t, res.Frames, 1) + assert.Equal(t, "time", res.Frames[0].Fields[0].Name) assert.Equal(t, "title", res.Frames[0].Fields[1].Name) assert.Equal(t, "tags", res.Frames[0].Fields[2].Name) assert.Equal(t, "text", res.Frames[0].Fields[3].Name) + assert.Equal(t, 9, res.Frames[0].Fields[0].Len()) + assert.Equal(t, 9, res.Frames[0].Fields[1].Len()) + assert.Equal(t, 9, res.Frames[0].Fields[2].Len()) + assert.Equal(t, 9, res.Frames[0].Fields[3].Len()) } func TestCloudMonitoringExecutor_parseToAnnotations_emptyTimeSeries(t *testing.T) { @@ -37,7 +42,15 @@ func TestCloudMonitoringExecutor_parseToAnnotations_emptyTimeSeries(t *testing.T err := query.parseToAnnotations(res, response, "atitle", "atext") require.NoError(t, err) - require.Len(t, res.Frames, 0) + require.Len(t, res.Frames, 1) + assert.Equal(t, "time", res.Frames[0].Fields[0].Name) + assert.Equal(t, "title", res.Frames[0].Fields[1].Name) + assert.Equal(t, "tags", res.Frames[0].Fields[2].Name) + assert.Equal(t, "text", res.Frames[0].Fields[3].Name) + assert.Equal(t, 0, res.Frames[0].Fields[0].Len()) + assert.Equal(t, 0, res.Frames[0].Fields[1].Len()) + assert.Equal(t, 0, res.Frames[0].Fields[2].Len()) + assert.Equal(t, 0, res.Frames[0].Fields[3].Len()) } func TestCloudMonitoringExecutor_parseToAnnotations_noPointsInSeries(t *testing.T) { @@ -53,5 +66,13 @@ func TestCloudMonitoringExecutor_parseToAnnotations_noPointsInSeries(t *testing. err := query.parseToAnnotations(res, response, "atitle", "atext") require.NoError(t, err) - require.Len(t, res.Frames, 0) + require.Len(t, res.Frames, 1) + assert.Equal(t, "time", res.Frames[0].Fields[0].Name) + assert.Equal(t, "title", res.Frames[0].Fields[1].Name) + assert.Equal(t, "tags", res.Frames[0].Fields[2].Name) + assert.Equal(t, "text", res.Frames[0].Fields[3].Name) + assert.Equal(t, 0, res.Frames[0].Fields[0].Len()) + assert.Equal(t, 0, res.Frames[0].Fields[1].Len()) + assert.Equal(t, 0, res.Frames[0].Fields[2].Len()) + assert.Equal(t, 0, res.Frames[0].Fields[3].Len()) } diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 9317e29b080..e1e131c1122 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -250,33 +250,36 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) handleNonDistributionSe func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseToAnnotations(dr *backend.DataResponse, response cloudMonitoringResponse, title, text string) error { - frames := data.Frames{} + frame := data.NewFrame(timeSeriesFilter.RefID, + data.NewField("time", nil, []time.Time{}), + data.NewField("title", nil, []string{}), + data.NewField("tags", nil, []string{}), + data.NewField("text", nil, []string{}), + ) + for _, series := range response.TimeSeries { if len(series.Points) == 0 { continue } - annotation := make(map[string][]string) + for i := len(series.Points) - 1; i >= 0; i-- { point := series.Points[i] value := strconv.FormatFloat(point.Value.DoubleValue, 'f', 6, 64) if series.ValueType == "STRING" { value = point.Value.StringValue } - annotation["time"] = append(annotation["time"], point.Interval.EndTime.UTC().Format(time.RFC3339)) - annotation["title"] = append(annotation["title"], formatAnnotationText(title, value, series.Metric.Type, - series.Metric.Labels, series.Resource.Labels)) - annotation["tags"] = append(annotation["tags"], "") - annotation["text"] = append(annotation["text"], formatAnnotationText(text, value, series.Metric.Type, - series.Metric.Labels, series.Resource.Labels)) + annotation := &annotationEvent{ + Time: point.Interval.EndTime, + Title: formatAnnotationText(title, value, series.Metric.Type, + series.Metric.Labels, series.Resource.Labels), + Tags: "", + Text: formatAnnotationText(text, value, series.Metric.Type, + series.Metric.Labels, series.Resource.Labels), + } + frame.AppendRow(annotation.Time, annotation.Title, annotation.Tags, annotation.Text) } - frames = append(frames, data.NewFrame(timeSeriesFilter.getRefID(), - data.NewField("time", nil, annotation["time"]), - data.NewField("title", nil, annotation["title"]), - data.NewField("tags", nil, annotation["tags"]), - data.NewField("text", nil, annotation["text"]), - )) } - dr.Frames = frames + dr.Frames = append(dr.Frames, frame) return nil } diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index addd562ece3..f7c1edf0a5d 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -266,7 +266,7 @@ func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *ba func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) parseToAnnotations(queryRes *backend.DataResponse, data cloudMonitoringResponse, title, text string) error { - annotations := make([]map[string]string, 0) + annotations := make([]*annotationEvent, 0) for _, series := range data.TimeSeriesData { metricLabels := make(map[string]string) @@ -302,12 +302,12 @@ func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) parseToAnnotations(queryRe if d.ValueType == "STRING" { value = point.Values[n].StringValue } - annotation := make(map[string]string) - annotation["time"] = point.TimeInterval.EndTime.UTC().Format(time.RFC3339) - annotation["title"] = formatAnnotationText(title, value, d.MetricKind, metricLabels, resourceLabels) - annotation["tags"] = "" - annotation["text"] = formatAnnotationText(text, value, d.MetricKind, metricLabels, resourceLabels) - annotations = append(annotations, annotation) + annotations = append(annotations, &annotationEvent{ + Time: point.TimeInterval.EndTime, + Title: formatAnnotationText(title, value, d.MetricKind, metricLabels, resourceLabels), + Tags: "", + Text: formatAnnotationText(text, value, d.MetricKind, metricLabels, resourceLabels), + }) } } } diff --git a/public/app/angular/angular_wrappers.ts b/public/app/angular/angular_wrappers.ts index bb9f7a845dd..3b02e5ddbeb 100644 --- a/public/app/angular/angular_wrappers.ts +++ b/public/app/angular/angular_wrappers.ts @@ -13,7 +13,6 @@ import { import { react2AngularDirective } from 'app/angular/react2angular'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings'; -import { AnnotationQueryEditor as CloudMonitoringAnnotationQueryEditor } from 'app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor'; import { QueryEditor as CloudMonitoringQueryEditor } from 'app/plugins/datasource/cloud-monitoring/components/QueryEditor'; import EmptyListCTA from '../core/components/EmptyListCTA/EmptyListCTA'; @@ -117,12 +116,6 @@ export function registerAngularDirectives() { ['datasource', { watchDepth: 'reference' }], ['templateSrv', { watchDepth: 'reference' }], ]); - react2AngularDirective('cloudMonitoringAnnotationQueryEditor', CloudMonitoringAnnotationQueryEditor, [ - 'target', - 'onQueryChange', - ['datasource', { watchDepth: 'reference' }], - ['templateSrv', { watchDepth: 'reference' }], - ]); react2AngularDirective('secretFormField', SecretFormField, [ 'value', 'isConfigured', diff --git a/public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringDatasource.ts b/public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringDatasource.ts new file mode 100644 index 00000000000..f05fe79d8ae --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringDatasource.ts @@ -0,0 +1,13 @@ +import Datasource from '../datasource'; + +export const createMockDatasource = () => { + const datasource: Partial = { + intervalMs: 0, + getVariables: jest.fn().mockReturnValue([]), + getMetricTypes: jest.fn().mockResolvedValue([]), + getProjects: jest.fn().mockResolvedValue([]), + getDefaultProject: jest.fn().mockReturnValue('cloud-monitoring-default-project'), + }; + + return jest.mocked(datasource as Datasource, true); +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringQuery.ts b/public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringQuery.ts new file mode 100644 index 00000000000..c9a41c09274 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/__mocks__/cloudMonitoringQuery.ts @@ -0,0 +1,21 @@ +import { CloudMonitoringQuery, EditorMode, MetricQuery, QueryType } from '../types'; + +export const createMockMetricQuery: () => MetricQuery = () => { + return { + editorMode: EditorMode.Visual, + metricType: '', + crossSeriesReducer: 'REDUCE_NONE', + query: '', + projectName: 'cloud-monitoring-default-project', + }; +}; + +export const createMockQuery: () => CloudMonitoringQuery = () => { + return { + refId: 'cloudMonitoringRefId', + queryType: QueryType.METRICS, + intervalMs: 0, + type: 'timeSeriesQuery', + metricQuery: createMockMetricQuery(), + }; +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/annotationSupport.test.ts b/public/app/plugins/datasource/cloud-monitoring/annotationSupport.test.ts new file mode 100644 index 00000000000..dbdcb196a7e --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/annotationSupport.test.ts @@ -0,0 +1,114 @@ +import { AnnotationQuery } from '@grafana/data'; + +import { createMockDatasource } from './__mocks__/cloudMonitoringDatasource'; +import { CloudMonitoringAnnotationSupport } from './annotationSupport'; +import { + AlignmentTypes, + CloudMonitoringQuery, + EditorMode, + LegacyCloudMonitoringAnnotationQuery, + MetricKind, + QueryType, +} from './types'; + +const query: CloudMonitoringQuery = { + refId: 'query', + queryType: QueryType.METRICS, + type: 'annotationQuery', + intervalMs: 0, + metricQuery: { + editorMode: EditorMode.Visual, + projectName: 'project-name', + metricType: '', + filters: [], + metricKind: MetricKind.GAUGE, + valueType: '', + title: '', + text: '', + query: '', + crossSeriesReducer: 'REDUCE_NONE', + perSeriesAligner: AlignmentTypes.ALIGN_NONE, + }, +}; + +const legacyQuery: LegacyCloudMonitoringAnnotationQuery = { + projectName: 'project-name', + metricType: 'metric-type', + filters: ['filter1', 'filter2'], + metricKind: MetricKind.CUMULATIVE, + valueType: 'value-type', + refId: 'annotationQuery', + title: 'title', + text: 'text', +}; + +const annotationQuery: AnnotationQuery = { + name: 'Anno', + enable: false, + iconColor: '', + target: query, +}; + +const legacyAnnotationQuery: AnnotationQuery = { + name: 'Anno', + enable: false, + iconColor: '', + target: legacyQuery, +}; + +const ds = createMockDatasource(); +const annotationSupport = CloudMonitoringAnnotationSupport(ds); + +describe('CloudMonitoringAnnotationSupport', () => { + describe('prepareAnnotation', () => { + it('returns query if it is already a Cloud Monitoring annotation query', () => { + expect(annotationSupport.prepareAnnotation?.(annotationQuery)).toBe(annotationQuery); + }); + it('returns an updated query if it is a legacy Cloud Monitoring annotation query', () => { + const expectedQuery = { + datasource: undefined, + enable: false, + iconColor: '', + name: 'Anno', + target: { + intervalMs: 0, + metricQuery: { + crossSeriesReducer: 'REDUCE_NONE', + editorMode: 'visual', + filters: ['filter1', 'filter2'], + metricKind: 'CUMULATIVE', + metricType: 'metric-type', + perSeriesAligner: 'ALIGN_NONE', + projectName: 'project-name', + query: '', + text: 'text', + title: 'title', + }, + queryType: 'metrics', + refId: 'annotationQuery', + type: 'annotationQuery', + }, + }; + expect(annotationSupport.prepareAnnotation?.(legacyAnnotationQuery)).toEqual(expectedQuery); + }); + }); + + describe('prepareQuery', () => { + it('should ensure queryType is set to "metrics"', () => { + const queryWithoutMetricsQueryType = { ...annotationQuery, queryType: 'blah' }; + expect(annotationSupport.prepareQuery?.(queryWithoutMetricsQueryType)).toEqual( + expect.objectContaining({ queryType: 'metrics' }) + ); + }); + it('should ensure type is set "annotationQuery"', () => { + const queryWithoutAnnotationQueryType = { ...annotationQuery, type: 'blah' }; + expect(annotationSupport.prepareQuery?.(queryWithoutAnnotationQueryType)).toEqual( + expect.objectContaining({ type: 'annotationQuery' }) + ); + }); + it('should return undefined if there is no query', () => { + const queryWithUndefinedTarget = { ...annotationQuery, target: undefined }; + expect(annotationSupport.prepareQuery?.(queryWithUndefinedTarget)).toBeUndefined(); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloud-monitoring/annotationSupport.ts b/public/app/plugins/datasource/cloud-monitoring/annotationSupport.ts new file mode 100644 index 00000000000..04f69d59054 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/annotationSupport.ts @@ -0,0 +1,78 @@ +import { AnnotationSupport, AnnotationQuery } from '@grafana/data'; + +import { AnnotationQueryEditor } from './components/AnnotationQueryEditor'; +import CloudMonitoringDatasource from './datasource'; +import { + AlignmentTypes, + CloudMonitoringQuery, + EditorMode, + LegacyCloudMonitoringAnnotationQuery, + MetricKind, + QueryType, +} from './types'; + +// The legacy query format sets the title and text values to empty strings by default. +// If the title or text is not undefined at the top-level of the annotation target, +// then it is a legacy query. +const isLegacyCloudMonitoringAnnotation = ( + query: unknown +): query is AnnotationQuery => + (query as AnnotationQuery).target?.title !== undefined || + (query as AnnotationQuery).target?.text !== undefined; + +export const CloudMonitoringAnnotationSupport: ( + ds: CloudMonitoringDatasource +) => AnnotationSupport = (ds: CloudMonitoringDatasource) => { + return { + prepareAnnotation: ( + query: AnnotationQuery | AnnotationQuery + ): AnnotationQuery => { + if (!isLegacyCloudMonitoringAnnotation(query)) { + return query; + } + + const { enable, name, iconColor } = query; + const { target } = query; + const result: AnnotationQuery = { + datasource: query.datasource, + enable, + name, + iconColor, + target: { + intervalMs: ds.intervalMs, + refId: target?.refId || 'annotationQuery', + type: 'annotationQuery', + queryType: QueryType.METRICS, + metricQuery: { + projectName: target?.projectName || ds.getDefaultProject(), + editorMode: EditorMode.Visual, + metricType: target?.metricType || '', + filters: target?.filters || [], + metricKind: target?.metricKind || MetricKind.GAUGE, + query: '', + crossSeriesReducer: 'REDUCE_NONE', + perSeriesAligner: AlignmentTypes.ALIGN_NONE, + title: target?.title || '', + text: target?.text || '', + }, + }, + }; + return result; + }, + prepareQuery: (anno: AnnotationQuery) => { + if (!anno.target) { + return undefined; + } + + return { + ...anno.target, + queryType: QueryType.METRICS, + type: 'annotationQuery', + metricQuery: { + ...anno.target.metricQuery, + }, + }; + }, + QueryEditor: AnnotationQueryEditor, + }; +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/annotations_query_ctrl.ts b/public/app/plugins/datasource/cloud-monitoring/annotations_query_ctrl.ts deleted file mode 100644 index 47478571660..00000000000 --- a/public/app/plugins/datasource/cloud-monitoring/annotations_query_ctrl.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AnnotationTarget } from './types'; - -export class CloudMonitoringAnnotationsQueryCtrl { - static templateUrl = 'partials/annotations.editor.html'; - declare annotation: any; - - /** @ngInject */ - constructor($scope: any) { - this.annotation = $scope.ctrl.annotation || {}; - this.annotation.target = $scope.ctrl.annotation.target || {}; - - this.onQueryChange = this.onQueryChange.bind(this); - } - - onQueryChange(target: AnnotationTarget) { - Object.assign(this.annotation.target, target); - } -} diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.test.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.test.tsx new file mode 100644 index 00000000000..5ac47371e7b --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { createMockDatasource } from '../__mocks__/cloudMonitoringDatasource'; +import { createMockQuery } from '../__mocks__/cloudMonitoringQuery'; + +import { AnnotationQueryEditor } from './AnnotationQueryEditor'; + +describe('AnnotationQueryEditor', () => { + it('renders correctly', async () => { + const onChange = jest.fn(); + const onRunQuery = jest.fn(); + const datasource = createMockDatasource(); + const query = createMockQuery(); + render(); + + expect(await screen.findByLabelText('Project')).toBeInTheDocument(); + expect(await screen.findByLabelText('Service')).toBeInTheDocument(); + expect(await screen.findByLabelText('Metric name')).toBeInTheDocument(); + expect(await screen.findByLabelText('Group by')).toBeInTheDocument(); + expect(await screen.findByLabelText('Group by function')).toBeInTheDocument(); + expect(await screen.findByLabelText('Alignment function')).toBeInTheDocument(); + expect(await screen.findByLabelText('Alignment period')).toBeInTheDocument(); + expect(await screen.findByLabelText('Alias by')).toBeInTheDocument(); + expect(await screen.findByLabelText('Title')).toBeInTheDocument(); + expect(await screen.findByLabelText('Text')).toBeInTheDocument(); + expect(await screen.findByText('Annotation Query Format')).toBeInTheDocument(); + }); + + it('can set the title', async () => { + const onChange = jest.fn(); + const onRunQuery = jest.fn(); + const datasource = createMockDatasource(); + const query = createMockQuery(); + render(); + + const title = 'user-title'; + await userEvent.type(screen.getByLabelText('Title'), title); + expect(await screen.findByDisplayValue(title)).toBeInTheDocument(); + }); + + it('can set the text', async () => { + const onChange = jest.fn(); + const onRunQuery = jest.fn(); + const datasource = createMockDatasource(); + const query = createMockQuery(); + render(); + + const text = 'user-text'; + await userEvent.type(screen.getByLabelText('Text'), text); + expect(await screen.findByDisplayValue(text)).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx index fa24c8ca8d0..806bac92516 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx @@ -1,34 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useDebounce } from 'react-use'; -import { SelectableValue, toOption } from '@grafana/data'; -import { TemplateSrv } from '@grafana/runtime'; -import { LegacyForms } from '@grafana/ui'; +import { QueryEditorProps, toOption } from '@grafana/data'; +import { Input } from '@grafana/ui'; +import { INPUT_WIDTH } from '../constants'; import CloudMonitoringDatasource from '../datasource'; -import { AnnotationTarget, EditorMode, MetricDescriptor, MetricKind } from '../types'; +import { + EditorMode, + MetricKind, + AnnotationMetricQuery, + CloudMonitoringOptions, + CloudMonitoringQuery, + AlignmentTypes, +} from '../types'; -import { AnnotationsHelp, LabelFilter, Metrics, Project, QueryEditorRow } from './'; +import { MetricQueryEditor } from './MetricQueryEditor'; -const { Input } = LegacyForms; +import { AnnotationsHelp, QueryEditorRow } from './'; -export interface Props { - refId: string; - onQueryChange: (target: AnnotationTarget) => void; - target: AnnotationTarget; - datasource: CloudMonitoringDatasource; - templateSrv: TemplateSrv; -} +export type Props = QueryEditorProps; -interface State extends AnnotationTarget { - variableOptionGroup: SelectableValue; - variableOptions: Array>; - labels: any; - [key: string]: any; -} - -const DefaultTarget: State = { +export const defaultQuery: (datasource: CloudMonitoringDatasource) => AnnotationMetricQuery = (datasource) => ({ editorMode: EditorMode.Visual, - projectName: '', + projectName: datasource.getDefaultProject(), projects: [], metricType: '', filters: [], @@ -40,112 +35,68 @@ const DefaultTarget: State = { labels: {}, variableOptionGroup: {}, variableOptions: [], -}; + query: '', + crossSeriesReducer: 'REDUCE_NONE', + perSeriesAligner: AlignmentTypes.ALIGN_NONE, + alignmentPeriod: 'grafana-auto', +}); -export class AnnotationQueryEditor extends React.Component { - state: State = DefaultTarget; - - async UNSAFE_componentWillMount() { - // Unfortunately, migrations like this need to go UNSAFE_componentWillMount. As soon as there's - // migration hook for this module.ts, we can do the migrations there instead. - const { target, datasource } = this.props; - if (!target.projectName) { - target.projectName = datasource.getDefaultProject(); - } - - const variableOptionGroup = { - label: 'Template Variables', - options: datasource.getVariables().map(toOption), - }; - - const projects = await datasource.getProjects(); - this.setState({ - variableOptionGroup, - variableOptions: variableOptionGroup.options, - ...target, - projects, - }); - - datasource - .getLabels(target.metricType, target.projectName, target.refId) - .then((labels) => this.setState({ labels })); - } - - onMetricTypeChange = ({ valueType, metricKind, type, unit }: MetricDescriptor) => { - const { onQueryChange, datasource } = this.props; - this.setState( - { - metricType: type, - unit, - valueType, - metricKind, - }, - () => { - onQueryChange(this.state); - } - ); - datasource.getLabels(type, this.state.refId, this.state.projectName).then((labels) => this.setState({ labels })); +export const AnnotationQueryEditor = (props: Props) => { + const { datasource, query, onRunQuery, data, onChange } = props; + const meta = data?.series.length ? data?.series[0].meta : {}; + const customMetaData = meta?.custom ?? {}; + const metricQuery = { ...defaultQuery(datasource), ...query.metricQuery }; + const [title, setTitle] = useState(metricQuery.title || ''); + const [text, setText] = useState(metricQuery.text || ''); + const variableOptionGroup = { + label: 'Template Variables', + options: datasource.getVariables().map(toOption), }; - onChange(prop: string, value: string | string[]) { - this.setState({ [prop]: value }, () => { - this.props.onQueryChange(this.state); - }); - } + const handleQueryChange = (metricQuery: AnnotationMetricQuery) => onChange({ ...query, metricQuery }); + const handleTitleChange = (e: React.ChangeEvent) => { + setTitle(e.target.value); + }; + const handleTextChange = (e: React.ChangeEvent) => { + setText(e.target.value); + }; - render() { - const { metricType, projectName, filters, title, text, variableOptionGroup, labels, variableOptions } = this.state; - const { datasource } = this.props; + useDebounce( + () => { + onChange({ ...query, metricQuery: { ...metricQuery, title } }); + }, + 1000, + [title, onChange] + ); + useDebounce( + () => { + onChange({ ...query, metricQuery: { ...metricQuery, text } }); + }, + 1000, + [text, onChange] + ); - return ( - <> - this.onChange('projectName', value)} - /> - this.onMetricTypeChange(metric)} - > - {(metric) => ( - <> - this.onChange('filters', value)} - variableOptionGroup={variableOptionGroup} - /> - - )} - + return ( + <> + - - this.onChange('title', e.target.value)} - /> - - - this.onChange('text', e.target.value)} - /> - + + + - - - ); - } -} + + + + + + + ); +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/constants.ts b/public/app/plugins/datasource/cloud-monitoring/constants.ts index 25922d5cdd1..62c490fbde9 100644 --- a/public/app/plugins/datasource/cloud-monitoring/constants.ts +++ b/public/app/plugins/datasource/cloud-monitoring/constants.ts @@ -13,6 +13,20 @@ export const AUTH_TYPES = [ ]; export const ALIGNMENTS = [ + { + text: 'none', + value: 'ALIGN_NONE', + valueTypes: [ + ValueTypes.INT64, + ValueTypes.DOUBLE, + ValueTypes.MONEY, + ValueTypes.DISTRIBUTION, + ValueTypes.STRING, + ValueTypes.VALUE_TYPE_UNSPECIFIED, + ValueTypes.BOOL, + ], + metricKinds: [MetricKind.GAUGE], + }, { text: 'delta', value: 'ALIGN_DELTA', diff --git a/public/app/plugins/datasource/cloud-monitoring/datasource.ts b/public/app/plugins/datasource/cloud-monitoring/datasource.ts index d1e84261f16..20b28f7a229 100644 --- a/public/app/plugins/datasource/cloud-monitoring/datasource.ts +++ b/public/app/plugins/datasource/cloud-monitoring/datasource.ts @@ -13,6 +13,7 @@ import { DataSourceWithBackend, getBackendSrv, toDataQueryResponse } from '@graf import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { CloudMonitoringAnnotationSupport } from './annotationSupport'; import { CloudMonitoringOptions, CloudMonitoringQuery, @@ -41,6 +42,7 @@ export default class CloudMonitoringDatasource extends DataSourceWithBackend< this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt'; this.variables = new CloudMonitoringVariableSupport(this); this.intervalMs = 0; + this.annotations = CloudMonitoringAnnotationSupport(this); } getVariables() { @@ -55,73 +57,15 @@ export default class CloudMonitoringDatasource extends DataSourceWithBackend< return super.query(request); } - async annotationQuery(options: any) { - await this.ensureGCEDefaultProject(); - const annotation = options.annotation; - const queries = [ - { - refId: 'annotationQuery', - type: 'annotationQuery', - datasource: this.getRef(), - view: 'FULL', - crossSeriesReducer: 'REDUCE_NONE', - perSeriesAligner: 'ALIGN_NONE', - metricType: this.templateSrv.replace(annotation.target.metricType, options.scopedVars || {}), - title: this.templateSrv.replace(annotation.target.title, options.scopedVars || {}), - text: this.templateSrv.replace(annotation.target.text, options.scopedVars || {}), - projectName: this.templateSrv.replace( - annotation.target.projectName ? annotation.target.projectName : this.getDefaultProject(), - options.scopedVars || {} - ), - filters: this.interpolateFilters(annotation.target.filters || [], options.scopedVars), - }, - ]; - - return lastValueFrom( - getBackendSrv() - .fetch({ - url: '/api/ds/query', - method: 'POST', - data: { - from: options.range.from.valueOf().toString(), - to: options.range.to.valueOf().toString(), - queries, - }, - }) - .pipe( - map(({ data }) => { - const dataQueryResponse = toDataQueryResponse({ - data: data, - }); - const df: any = []; - if (dataQueryResponse.data.length !== 0) { - for (let i = 0; i < dataQueryResponse.data.length; i++) { - for (let j = 0; j < dataQueryResponse.data[i].fields[0].values.length; j++) { - df.push({ - annotation: annotation, - time: Date.parse(dataQueryResponse.data[i].fields[0].values.get(j)), - title: dataQueryResponse.data[i].fields[1].values.get(j), - tags: [], - text: dataQueryResponse.data[i].fields[3].values.get(j), - }); - } - } - } - return df; - }) - ) - ); - } - applyTemplateVariables( - { metricQuery, refId, queryType, sloQuery }: CloudMonitoringQuery, + { metricQuery, refId, queryType, sloQuery, type = 'timeSeriesQuery' }: CloudMonitoringQuery, scopedVars: ScopedVars ): Record { return { datasource: this.getRef(), refId, intervalMs: this.intervalMs, - type: 'timeSeriesQuery', + type, queryType, metricQuery: { ...this.interpolateProps(metricQuery, scopedVars), diff --git a/public/app/plugins/datasource/cloud-monitoring/functions.test.ts b/public/app/plugins/datasource/cloud-monitoring/functions.test.ts index 118e76f4930..3e5c2409b58 100644 --- a/public/app/plugins/datasource/cloud-monitoring/functions.test.ts +++ b/public/app/plugins/datasource/cloud-monitoring/functions.test.ts @@ -127,7 +127,7 @@ describe('functions', () => { }); it('should return all alignment options except two', () => { - expect(result.length).toBe(9); + expect(result.length).toBe(10); expect(result.map((o: any) => o.value)).toEqual( expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE']) ); @@ -173,7 +173,7 @@ describe('functions', () => { describe('getAlignmentPickerData', () => { it('should return default data', () => { const res = getAlignmentPickerData(); - expect(res.alignOptions).toHaveLength(9); + expect(res.alignOptions).toHaveLength(10); expect(res.perSeriesAligner).toEqual(AlignmentTypes.ALIGN_MEAN); }); diff --git a/public/app/plugins/datasource/cloud-monitoring/module.ts b/public/app/plugins/datasource/cloud-monitoring/module.ts index 64f25908f59..42ea96502cf 100644 --- a/public/app/plugins/datasource/cloud-monitoring/module.ts +++ b/public/app/plugins/datasource/cloud-monitoring/module.ts @@ -1,6 +1,5 @@ import { DataSourcePlugin } from '@grafana/data'; -import { CloudMonitoringAnnotationsQueryCtrl } from './annotations_query_ctrl'; import CloudMonitoringCheatSheet from './components/CloudMonitoringCheatSheet'; import { ConfigEditor } from './components/ConfigEditor/ConfigEditor'; import { QueryEditor } from './components/QueryEditor'; @@ -12,5 +11,4 @@ export const plugin = new DataSourcePlugin diff --git a/public/app/plugins/datasource/cloud-monitoring/types.ts b/public/app/plugins/datasource/cloud-monitoring/types.ts index 8081d8c0ed0..216306babf3 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types.ts @@ -106,6 +106,7 @@ export enum AlignmentTypes { ALIGN_PERCENTILE_50 = 'ALIGN_PERCENTILE_50', ALIGN_PERCENTILE_05 = 'ALIGN_PERCENTILE_05', ALIGN_PERCENT_CHANGE = 'ALIGN_PERCENT_CHANGE', + ALIGN_NONE = 'ALIGN_NONE', } export interface BaseQuery { @@ -130,6 +131,11 @@ export interface MetricQuery extends BaseQuery { graphPeriod?: 'disabled' | string; } +export interface AnnotationMetricQuery extends MetricQuery { + title?: string; + text?: string; +} + export interface SLOQuery extends BaseQuery { selectorName: string; serviceId: string; @@ -142,7 +148,7 @@ export interface SLOQuery extends BaseQuery { export interface CloudMonitoringQuery extends DataQuery { datasourceId?: number; // Should not be necessary anymore queryType: QueryType; - metricQuery: MetricQuery; + metricQuery: MetricQuery | AnnotationMetricQuery; sloQuery?: SLOQuery; intervalMs: number; type: string; @@ -160,7 +166,7 @@ export interface CloudMonitoringSecureJsonData { privateKey?: string; } -export interface AnnotationTarget { +export interface LegacyCloudMonitoringAnnotationQuery { projectName: string; metricType: string; refId: string;