diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 8f4cd9ffea2..f57342ff48d 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -14,8 +14,10 @@ export interface TermCount { } export interface Props { + allowCustomValue?: boolean; /** Do not show selected values inside Select. Useful when the values need to be shown in some other components */ hideValues?: boolean; + inputId?: string; isClearable?: boolean; onChange: (tags: string[]) => void; placeholder?: string; @@ -30,7 +32,9 @@ const filterOption = (option: any, searchQuery: string) => { }; export const TagFilter: FC = ({ + allowCustomValue = false, hideValues, + inputId, isClearable, onChange, placeholder = 'Filter by tag', @@ -60,10 +64,12 @@ export const TagFilter: FC = ({ const value = tags.map((tag) => ({ value: tag, label: tag, count: 0 })); const selectOptions = { + allowCustomValue, defaultOptions: true, filterOption, getOptionLabel: (i: any) => i.label, getOptionValue: (i: any) => i.value, + inputId, isMulti: true, loadOptions: onLoadOptions, loadingMessage: 'Loading...', diff --git a/public/app/core/components/TagFilter/TagOption.tsx b/public/app/core/components/TagFilter/TagOption.tsx index 7352fd2d498..8448d01e279 100644 --- a/public/app/core/components/TagFilter/TagOption.tsx +++ b/public/app/core/components/TagFilter/TagOption.tsx @@ -18,7 +18,7 @@ export const TagOption: FC = ({ data, className, label, isF return (
- +
); diff --git a/public/app/features/annotations/api.ts b/public/app/features/annotations/api.ts index 113ebbaa733..cdbaab32faf 100644 --- a/public/app/features/annotations/api.ts +++ b/public/app/features/annotations/api.ts @@ -1,5 +1,6 @@ import { AnnotationEvent } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; +import { AnnotationTagsResponse } from './types'; export function saveAnnotation(annotation: AnnotationEvent) { return getBackendSrv().post('/api/annotations', annotation); @@ -12,3 +13,11 @@ export function updateAnnotation(annotation: AnnotationEvent) { export function deleteAnnotation(annotation: AnnotationEvent) { return getBackendSrv().delete(`/api/annotations/${annotation.id}`); } + +export async function getAnnotationTags() { + const response: AnnotationTagsResponse = await getBackendSrv().get('/api/annotations/tags'); + return response.result.tags.map(({ tag, count }) => ({ + term: tag, + count, + })); +} diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx index 3a223a3185a..587a5678d67 100644 --- a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -175,10 +175,12 @@ export default class StandardAnnotationQueryEditor extends PureComponent - {this.renderStatus()} - - -
+ {datasource.type !== 'datasource' && ( + <> + {this.renderStatus()} + + + )} ); } diff --git a/public/app/features/annotations/types.ts b/public/app/features/annotations/types.ts index eaf0e43a859..e0a53d516e9 100644 --- a/public/app/features/annotations/types.ts +++ b/public/app/features/annotations/types.ts @@ -18,3 +18,20 @@ export interface AnnotationQueryResponse { */ panelData?: PanelData; } + +export interface AnnotationTag { + /** + * The tag name + */ + tag: string; + /** + * The number of occurences of that tag + */ + count: number; +} + +export interface AnnotationTagsResponse { + result: { + tags: AnnotationTag[]; + }; +} diff --git a/public/app/plugins/datasource/grafana/annotation_ctrl.ts b/public/app/plugins/datasource/grafana/annotation_ctrl.ts deleted file mode 100644 index 7dd200f4a9d..00000000000 --- a/public/app/plugins/datasource/grafana/annotation_ctrl.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SelectableValue } from '@grafana/data'; -import { GrafanaAnnotationType } from './types'; - -export const annotationTypes: Array> = [ - { text: 'Dashboard', value: GrafanaAnnotationType.Dashboard }, - { text: 'Tags', value: GrafanaAnnotationType.Tags }, -]; - -export class GrafanaAnnotationsQueryCtrl { - declare annotation: any; - - types = annotationTypes; - - /** @ngInject */ - constructor($scope: any) { - this.annotation = $scope.ctrl.annotation; - this.annotation.type = this.annotation.type || GrafanaAnnotationType.Tags; - this.annotation.limit = this.annotation.limit || 100; - } - - static templateUrl = 'partials/annotations.editor.html'; -} diff --git a/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.test.tsx b/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.test.tsx new file mode 100644 index 00000000000..961b02ae012 --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQueryType } from '../types'; +import AnnotationQueryEditor from './AnnotationQueryEditor'; + +describe('AnnotationQueryEditor', () => { + const mockOnChange = jest.fn(); + let mockQuery: GrafanaAnnotationQuery; + + beforeEach(() => { + mockQuery = { + queryType: GrafanaQueryType.Annotations, + refId: 'Anno', + type: GrafanaAnnotationType.Tags, + limit: 100, + }; + }); + + it('has a "Filter by" input', () => { + render(); + const filterBy = screen.getByLabelText('Filter by'); + expect(filterBy).toBeInTheDocument(); + }); + + it('has a "Max limit" input', () => { + render(); + const maxLimit = screen.getByLabelText('Max limit'); + expect(maxLimit).toBeInTheDocument(); + }); + + describe('when the query type is "Tags" and the tags array is present', () => { + beforeEach(() => { + mockQuery.tags = []; + }); + + it('has a "Match any" toggle', () => { + render(); + const matchAny = screen.getByLabelText(/Match any/); + expect(matchAny).toBeInTheDocument(); + }); + + it('has a "Tags" input', () => { + render(); + const tags = screen.getByLabelText(/Tags/); + expect(tags).toBeInTheDocument(); + }); + }); + + describe('when the query type is "Dashboard"', () => { + beforeEach(() => { + mockQuery.type = GrafanaAnnotationType.Dashboard; + }); + + it('does not have a "Match any" toggle', () => { + render(); + const matchAny = screen.queryByLabelText('Match any'); + expect(matchAny).toBeNull(); + }); + + it('does not have a "Tags" input', () => { + render(); + const tags = screen.queryByLabelText('Tags'); + expect(tags).toBeNull(); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx new file mode 100644 index 00000000000..ff1ee696762 --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { Field, FieldSet, Select, Switch } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; +import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQuery } from '../types'; +import { getAnnotationTags } from 'app/features/annotations/api'; + +const matchTooltipContent = 'Enabling this returns annotations that match any of the tags specified below'; + +const tagsTooltipContent = ( +
Specify a list of tags to match. To specify a key and value tag use `key:value` syntax.
+); + +const annotationTypes = [ + { + label: 'Dashboard', + value: GrafanaAnnotationType.Dashboard, + description: 'Query for events created on this dashboard and show them in the panels where they where created', + }, + { + label: 'Tags', + value: GrafanaAnnotationType.Tags, + description: 'This will fetch any annotation events that match the tags filter', + }, +]; + +const limitOptions = [10, 50, 100, 200, 300, 500, 1000, 2000].map((limit) => ({ + label: String(limit), + value: limit, +})); + +interface Props { + query: GrafanaQuery; + onChange: (newValue: GrafanaAnnotationQuery) => void; +} + +export default function AnnotationQueryEditor({ query, onChange }: Props) { + const annotationQuery = query as GrafanaAnnotationQuery; + const { limit, matchAny, tags, type } = annotationQuery; + const styles = getStyles(); + + const onFilterByChange = (newValue: SelectableValue) => + onChange({ + ...annotationQuery, + type: newValue.value!, + }); + + const onMaxLimitChange = (newValue: SelectableValue) => + onChange({ + ...annotationQuery, + limit: newValue.value!, + }); + + const onMatchAnyChange = (newValue: React.ChangeEvent) => + onChange({ + ...annotationQuery, + matchAny: newValue.target.checked, + }); + + const onTagsChange = (tags: string[]) => + onChange({ + ...annotationQuery, + tags, + }); + + return ( +
+ + + + {type === GrafanaAnnotationType.Tags && tags && ( + <> + + + + + + + + )} +
+ ); +} + +const getStyles = () => { + return { + container: css` + max-width: 600px; + `, + }; +}; diff --git a/public/app/plugins/datasource/grafana/datasource.test.ts b/public/app/plugins/datasource/grafana/datasource.test.ts index c107003dd37..4239597fb80 100644 --- a/public/app/plugins/datasource/grafana/datasource.test.ts +++ b/public/app/plugins/datasource/grafana/datasource.test.ts @@ -1,8 +1,8 @@ -import { DataSourceInstanceSettings, dateTime, AnnotationQueryRequest } from '@grafana/data'; +import { AnnotationQueryRequest, DataSourceInstanceSettings, dateTime } from '@grafana/data'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { GrafanaDatasource } from './datasource'; -import { GrafanaQuery, GrafanaAnnotationQuery, GrafanaAnnotationType } from './types'; +import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQuery } from './types'; jest.mock('@grafana/runtime', () => ({ ...((jest.requireActual('@grafana/runtime') as unknown) as object), @@ -37,7 +37,7 @@ describe('grafana data source', () => { const options = setupAnnotationQueryOptions({ tags: ['tag1:$var'] }); beforeEach(() => { - return ds.annotationQuery(options); + return ds.getAnnotations(options); }); it('should interpolate template variables in tags in query options', () => { @@ -49,7 +49,7 @@ describe('grafana data source', () => { const options = setupAnnotationQueryOptions({ tags: ['$var2'] }); beforeEach(() => { - return ds.annotationQuery(options); + return ds.getAnnotations(options); }); it('should interpolate template variables in tags in query options', () => { @@ -68,7 +68,7 @@ describe('grafana data source', () => { ); beforeEach(() => { - return ds.annotationQuery(options); + return ds.getAnnotations(options); }); it('should remove tags from query options', () => { @@ -80,7 +80,9 @@ describe('grafana data source', () => { function setupAnnotationQueryOptions(annotation: Partial, dashboard?: { id: number }) { return ({ - annotation, + annotation: { + target: annotation, + }, dashboard, range: { from: dateTime(1432288354), diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index 5ff3617edc6..7750db46625 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -1,5 +1,8 @@ +import { from, merge, Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { getBackendSrv, getGrafanaLiveSrv, getTemplateSrv, toDataQueryResponse } from '@grafana/runtime'; import { - AnnotationEvent, + AnnotationQuery, AnnotationQueryRequest, DataQueryRequest, DataQueryResponse, @@ -8,24 +11,51 @@ import { isValidLiveChannelAddress, parseLiveChannelAddress, StreamingFrameOptions, + toDataFrame, } from '@grafana/data'; -import { GrafanaQuery, GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQueryType } from './types'; -import { getBackendSrv, getGrafanaLiveSrv, getTemplateSrv, toDataQueryResponse } from '@grafana/runtime'; -import { Observable, of, merge } from 'rxjs'; -import { map, catchError } from 'rxjs/operators'; +import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQuery, GrafanaQueryType } from './types'; +import AnnotationQueryEditor from './components/AnnotationQueryEditor'; +import { getDashboardSrv } from '../../../features/dashboard/services/DashboardSrv'; let counter = 100; export class GrafanaDatasource extends DataSourceApi { constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); + this.annotations = { + QueryEditor: AnnotationQueryEditor, + prepareAnnotation(json: any): AnnotationQuery { + // Previously, these properties lived outside of target + // This should handle migrating them + json.target = json.target ?? { + type: json.type ?? GrafanaAnnotationType.Dashboard, + limit: json.limit ?? 100, + tags: json.tags ?? [], + matchAny: json.matchAny ?? false, + }; // using spread syntax caused an infinite loop in StandardAnnotationQueryEditor + return json; + }, + prepareQuery(anno: AnnotationQuery): GrafanaQuery { + return { ...anno, refId: anno.name, queryType: GrafanaQueryType.Annotations }; + }, + }; } query(request: DataQueryRequest): Observable { const queries: Array> = []; const templateSrv = getTemplateSrv(); for (const target of request.targets) { + if (target.queryType === GrafanaQueryType.Annotations) { + return from( + this.getAnnotations({ + range: request.range, + rangeRaw: request.range.raw, + annotation: (target as unknown) as AnnotationQuery, + dashboard: getDashboardSrv().getCurrent(), + }) + ); + } if (target.hide) { continue; } @@ -80,21 +110,22 @@ export class GrafanaDatasource extends DataSourceApi { return Promise.resolve([]); } - annotationQuery(options: AnnotationQueryRequest): Promise { + async getAnnotations(options: AnnotationQueryRequest): Promise { const templateSrv = getTemplateSrv(); - const annotation = (options.annotation as unknown) as GrafanaAnnotationQuery; + const annotation = (options.annotation as unknown) as AnnotationQuery; + const target = annotation.target!; const params: any = { from: options.range.from.valueOf(), to: options.range.to.valueOf(), - limit: annotation.limit, - tags: annotation.tags, - matchAny: annotation.matchAny, + limit: target.limit, + tags: target.tags, + matchAny: target.matchAny, }; - if (annotation.type === GrafanaAnnotationType.Dashboard) { + if (target.type === GrafanaAnnotationType.Dashboard) { // if no dashboard id yet return if (!options.dashboard.id) { - return Promise.resolve([]); + return Promise.resolve({ data: [] }); } // filter by dashboard id params.dashboardId = options.dashboard.id; @@ -102,8 +133,8 @@ export class GrafanaDatasource extends DataSourceApi { delete params.tags; } else { // require at least one tag - if (!Array.isArray(annotation.tags) || annotation.tags.length === 0) { - return Promise.resolve([]); + if (!Array.isArray(target.tags) || target.tags.length === 0) { + return Promise.resolve({ data: [] }); } const delimiter = '__delimiter__'; const tags = []; @@ -122,11 +153,12 @@ export class GrafanaDatasource extends DataSourceApi { params.tags = tags; } - return getBackendSrv().get( + const annotations = await getBackendSrv().get( '/api/annotations', params, `grafana-data-source-annotations-${annotation.name}-${options.dashboard?.id}` ); + return { data: [toDataFrame(annotations)] }; } testDatasource() { diff --git a/public/app/plugins/datasource/grafana/module.ts b/public/app/plugins/datasource/grafana/module.ts index 4fff367be85..4fc4b8aaad4 100644 --- a/public/app/plugins/datasource/grafana/module.ts +++ b/public/app/plugins/datasource/grafana/module.ts @@ -2,8 +2,7 @@ import { DataSourcePlugin } from '@grafana/data'; import { GrafanaDatasource } from './datasource'; import { QueryEditor } from './components/QueryEditor'; import { GrafanaQuery } from './types'; -import { GrafanaAnnotationsQueryCtrl } from './annotation_ctrl'; -export const plugin = new DataSourcePlugin(GrafanaDatasource) - .setQueryEditor(QueryEditor) - .setAnnotationQueryCtrl(GrafanaAnnotationsQueryCtrl); +export const plugin = new DataSourcePlugin(GrafanaDatasource).setQueryEditor( + QueryEditor +); diff --git a/public/app/plugins/datasource/grafana/partials/annotations.editor.html b/public/app/plugins/datasource/grafana/partials/annotations.editor.html deleted file mode 100644 index c1164f7f8c7..00000000000 --- a/public/app/plugins/datasource/grafana/partials/annotations.editor.html +++ /dev/null @@ -1,54 +0,0 @@ - -
-
-
- - Filter by - -
    -
  • Dashboard: This will fetch annotation and alert state changes for whole dashboard and show them only on the event's originating panel.
  • -
  • Tags: This will fetch any annotation events that match the tags filter.
  • -
-
-
-
- -
-
-
- Max limit -
- -
-
-
-
-
- -
-
- - Tags - - A tag entered here as 'foo' will match -
    -
  • annotation tags 'foo'
  • -
  • annotation key-value tags formatted as 'foo:bar'
  • -
-
-
- - -
-
-
- - diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts index ccace7bf98c..a51002bda7e 100644 --- a/public/app/plugins/datasource/grafana/types.ts +++ b/public/app/plugins/datasource/grafana/types.ts @@ -1,4 +1,4 @@ -import { AnnotationQuery, DataQuery } from '@grafana/data'; +import { DataQuery } from '@grafana/data'; import { LiveDataFilter } from '@grafana/runtime'; //---------------------------------------------- @@ -8,6 +8,7 @@ import { LiveDataFilter } from '@grafana/runtime'; export enum GrafanaQueryType { RandomWalk = 'randomWalk', LiveMeasurements = 'measurements', + Annotations = 'annotations', } export interface GrafanaQuery extends DataQuery { @@ -31,7 +32,7 @@ export enum GrafanaAnnotationType { Tags = 'tags', } -export interface GrafanaAnnotationQuery extends AnnotationQuery { +export interface GrafanaAnnotationQuery extends GrafanaQuery { type: GrafanaAnnotationType; // tags limit: number; // 100 tags?: string[];