Annotations: Add typeahead support for tags in builtin annotations (#36377)

* Annotations: create React component, naive attempt at hooking together

* Annotations: Use query object instead of passing annotation

* Annotations: Hook up the new api to get annotation tags

* Annotations: Use InlineFieldRow instead of gf-form-inline

* Annotations: Use InlineSwitch instead of gf-form-switch

* TagFilter: Add support for allowCustomValue

* Annotations: Update to match backend api

* Annotations: Add basic tests, expose inputId on `TagFilter`

* Annotations: Fix test name and reorder tests slightly

* Annotations: Use FieldSet instead of gf-form-group

* Refactor: fixes annotation queries

* Annotations: Everything working, just types to fix...

* Annotations: Fix types?

* Revert "Annotations: Fix types?"

This reverts commit 6df0cae0c9.

* Annotations: Fix types again?

* Annotations: Remove old angular code

* Annotations: Fix unit tests for AnnotationQueryEditor

* Annotations: Check if it's an annotation query immediately

* Annotations: Prevent TagFilter overflowing container when there are a large number of tags

* Change to new form styles

* Annotations: Add id's + fix unit tests

* Updated wording

* Annotations: Allow custom value to preserve being able to use template variables

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ashley Harrison 2021-07-06 10:50:46 +01:00 committed by GitHub
parent 96a3cc3cd8
commit cc4d301d50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 280 additions and 108 deletions

View File

@ -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<Props> = ({
allowCustomValue = false,
hideValues,
inputId,
isClearable,
onChange,
placeholder = 'Filter by tag',
@ -60,10 +64,12 @@ export const TagFilter: FC<Props> = ({
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...',

View File

@ -18,7 +18,7 @@ export const TagOption: FC<ExtendedOptionProps> = ({ data, className, label, isF
return (
<div className={cx(styles.option, isFocused && styles.optionFocused)} aria-label="Tag option" {...innerProps}>
<div className={`tag-filter-option ${className || ''}`}>
<TagBadge label={label} removeIcon={false} count={data.count} />
<TagBadge label={label} removeIcon={false} count={data.count ?? 0} />
</div>
</div>
);

View File

@ -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,
}));
}

View File

@ -175,10 +175,12 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
data={response?.panelData}
range={getTimeSrv().timeRange()}
/>
{this.renderStatus()}
<AnnotationFieldMapper response={response} mappings={annotation.mappings} change={this.onMappingChange} />
<br />
{datasource.type !== 'datasource' && (
<>
{this.renderStatus()}
<AnnotationFieldMapper response={response} mappings={annotation.mappings} change={this.onMappingChange} />
</>
)}
</>
);
}

View File

@ -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[];
};
}

View File

@ -1,22 +0,0 @@
import { SelectableValue } from '@grafana/data';
import { GrafanaAnnotationType } from './types';
export const annotationTypes: Array<SelectableValue<GrafanaAnnotationType>> = [
{ 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';
}

View File

@ -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(<AnnotationQueryEditor query={mockQuery} onChange={mockOnChange} />);
const filterBy = screen.getByLabelText('Filter by');
expect(filterBy).toBeInTheDocument();
});
it('has a "Max limit" input', () => {
render(<AnnotationQueryEditor query={mockQuery} onChange={mockOnChange} />);
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(<AnnotationQueryEditor query={mockQuery} onChange={mockOnChange} />);
const matchAny = screen.getByLabelText(/Match any/);
expect(matchAny).toBeInTheDocument();
});
it('has a "Tags" input', () => {
render(<AnnotationQueryEditor query={mockQuery} onChange={mockOnChange} />);
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(<AnnotationQueryEditor query={mockQuery} onChange={mockOnChange} />);
const matchAny = screen.queryByLabelText('Match any');
expect(matchAny).toBeNull();
});
it('does not have a "Tags" input', () => {
render(<AnnotationQueryEditor query={mockQuery} onChange={mockOnChange} />);
const tags = screen.queryByLabelText('Tags');
expect(tags).toBeNull();
});
});
});

View File

@ -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 = (
<div>Specify a list of tags to match. To specify a key and value tag use `key:value` syntax.</div>
);
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<GrafanaAnnotationType>) =>
onChange({
...annotationQuery,
type: newValue.value!,
});
const onMaxLimitChange = (newValue: SelectableValue<number>) =>
onChange({
...annotationQuery,
limit: newValue.value!,
});
const onMatchAnyChange = (newValue: React.ChangeEvent<HTMLInputElement>) =>
onChange({
...annotationQuery,
matchAny: newValue.target.checked,
});
const onTagsChange = (tags: string[]) =>
onChange({
...annotationQuery,
tags,
});
return (
<FieldSet className={styles.container}>
<Field label="Filter by">
<Select
inputId="grafana-annotations__filter-by"
options={annotationTypes}
value={type}
onChange={onFilterByChange}
/>
</Field>
<Field label="Max limit">
<Select
inputId="grafana-annotations__limit"
width={16}
options={limitOptions}
value={limit}
onChange={onMaxLimitChange}
/>
</Field>
{type === GrafanaAnnotationType.Tags && tags && (
<>
<Field label="Match any" description={matchTooltipContent}>
<Switch id="grafana-annotations__match-any" value={matchAny} onChange={onMatchAnyChange} />
</Field>
<Field label="Tags" description={tagsTooltipContent}>
<TagFilter
allowCustomValue
inputId="grafana-annotations__tags"
onChange={onTagsChange}
tagOptions={getAnnotationTags}
tags={tags}
/>
</Field>
</>
)}
</FieldSet>
);
}
const getStyles = () => {
return {
container: css`
max-width: 600px;
`,
};
};

View File

@ -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<GrafanaAnnotationQuery>, dashboard?: { id: number }) {
return ({
annotation,
annotation: {
target: annotation,
},
dashboard,
range: {
from: dateTime(1432288354),

View File

@ -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<GrafanaQuery> {
constructor(instanceSettings: DataSourceInstanceSettings) {
super(instanceSettings);
this.annotations = {
QueryEditor: AnnotationQueryEditor,
prepareAnnotation(json: any): AnnotationQuery<GrafanaAnnotationQuery> {
// 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<GrafanaAnnotationQuery>): GrafanaQuery {
return { ...anno, refId: anno.name, queryType: GrafanaQueryType.Annotations };
},
};
}
query(request: DataQueryRequest<GrafanaQuery>): Observable<DataQueryResponse> {
const queries: Array<Observable<DataQueryResponse>> = [];
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<GrafanaAnnotationQuery>,
dashboard: getDashboardSrv().getCurrent(),
})
);
}
if (target.hide) {
continue;
}
@ -80,21 +110,22 @@ export class GrafanaDatasource extends DataSourceApi<GrafanaQuery> {
return Promise.resolve([]);
}
annotationQuery(options: AnnotationQueryRequest<GrafanaQuery>): Promise<AnnotationEvent[]> {
async getAnnotations(options: AnnotationQueryRequest<GrafanaQuery>): Promise<DataQueryResponse> {
const templateSrv = getTemplateSrv();
const annotation = (options.annotation as unknown) as GrafanaAnnotationQuery;
const annotation = (options.annotation as unknown) as AnnotationQuery<GrafanaAnnotationQuery>;
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<GrafanaQuery> {
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<GrafanaQuery> {
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() {

View File

@ -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, GrafanaQuery>(GrafanaDatasource)
.setQueryEditor(QueryEditor)
.setAnnotationQueryCtrl(GrafanaAnnotationsQueryCtrl);
export const plugin = new DataSourcePlugin<GrafanaDatasource, GrafanaQuery>(GrafanaDatasource).setQueryEditor(
QueryEditor
);

View File

@ -1,54 +0,0 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">
Filter by
<info-popover mode="right-normal">
<ul>
<li>Dashboard: This will fetch annotation and alert state changes for whole dashboard and show them only on the event's originating panel.</li>
<li>Tags: This will fetch any annotation events that match the tags filter.</li>
</ul>
</info-popover>
</span>
<div class="gf-form-select-wrapper width-8">
<select class="gf-form-input" ng-model="ctrl.annotation.type" ng-options="f.value as f.text for f in ctrl.types">
</select>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label">Max limit</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.annotation.limit" ng-options="f for f in [10,50,100,200,300,500,1000,2000]">
</select>
</div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form" ng-if="ctrl.annotation.type === 'tags'">
<gf-form-switch
class="gf-form"
label="Match any"
label-class="width-9"
checked="ctrl.annotation.matchAny"
on-change="ctrl.refresh()"
tooltip="By default Grafana only shows annotations that match all tags in the query. Enabling this returns annotations that match any of the tags in the query."></gf-form-switch>
</div>
<div class="gf-form" ng-if="ctrl.annotation.type === 'tags'">
<span class="gf-form-label">
Tags
<info-popover mode="right-normal">
A tag entered here as 'foo' will match
<ul>
<li>annotation tags 'foo'</li>
<li>annotation key-value tags formatted as 'foo:bar'</li>
</ul>
</info-popover>
</span>
<bootstrap-tagsinput ng-model="ctrl.annotation.tags" tagclass="label label-tag" placeholder="add tags">
</bootstrap-tagsinput>
</div>
</div>
</div>

View File

@ -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<GrafanaQuery> {
export interface GrafanaAnnotationQuery extends GrafanaQuery {
type: GrafanaAnnotationType; // tags
limit: number; // 100
tags?: string[];