From 82d80154694c73cab1004fdbf688ba85979b3bda Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Mon, 3 Oct 2022 17:00:46 -0400 Subject: [PATCH] OpenTSDB: Convert the OpenTSDB Query Editor from Angular to React (#54677) * add query editor to module * add metric section, add tests, update query type * remove use of any for betterer * fix test * add tooltip for alias * run runQuery on select change, fix metric select loading * add downsample row, differentiate for tsdb version * add tests for mteric section and downsample section * add filter section react component and tests * add tag section and tests * add rate section and tests * remove angular code * fix styling * remove comments * remove unused code --- .betterer.results | 33 --- .../opentsdb/components/DownSample.test.tsx | 74 +++++ .../opentsdb/components/DownSample.tsx | 103 +++++++ .../components/FilterSection.test.tsx | 107 +++++++ .../opentsdb/components/FilterSection.tsx | 246 ++++++++++++++++ .../components/MetricSection.test.tsx | 68 +++++ .../opentsdb/components/MetricSection.tsx | 110 ++++++++ .../components/OpenTsdbQueryEditor.test.tsx | 39 +++ .../components/OpenTsdbQueryEditor.tsx | 160 +++++++++++ .../opentsdb/components/RateSection.test.tsx | 68 +++++ .../opentsdb/components/RateSection.tsx | 107 +++++++ .../opentsdb/components/TagSection.test.tsx | 104 +++++++ .../opentsdb/components/TagSection.tsx | 219 +++++++++++++++ .../datasource/opentsdb/components/styles.ts | 5 + .../plugins/datasource/opentsdb/datasource.ts | 11 +- .../app/plugins/datasource/opentsdb/module.ts | 4 +- .../opentsdb/partials/query.editor.html | 264 ------------------ .../plugins/datasource/opentsdb/query_ctrl.ts | 225 --------------- .../opentsdb/specs/query_ctrl.test.ts | 93 ------ .../app/plugins/datasource/opentsdb/types.ts | 33 ++- 20 files changed, 1451 insertions(+), 622 deletions(-) create mode 100644 public/app/plugins/datasource/opentsdb/components/DownSample.test.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/DownSample.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/FilterSection.test.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/FilterSection.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/MetricSection.test.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/MetricSection.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/RateSection.test.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/RateSection.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/TagSection.test.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/TagSection.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/styles.ts delete mode 100644 public/app/plugins/datasource/opentsdb/partials/query.editor.html delete mode 100644 public/app/plugins/datasource/opentsdb/query_ctrl.ts delete mode 100644 public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts diff --git a/.betterer.results b/.betterer.results index e8f91a1b754..925d7652935 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6850,43 +6850,10 @@ exports[`better eslint`] = { "public/app/plugins/datasource/opentsdb/migrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/opentsdb/query_ctrl.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"], - [0, 0, 0, "Unexpected any. Specify a different type.", "22"], - [0, 0, 0, "Unexpected any. Specify a different type.", "23"] - ], "public/app/plugins/datasource/opentsdb/specs/datasource.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/plugins/datasource/opentsdb/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/postgres/datasource.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/public/app/plugins/datasource/opentsdb/components/DownSample.test.tsx b/public/app/plugins/datasource/opentsdb/components/DownSample.test.tsx new file mode 100644 index 00000000000..3f903d10ec4 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/DownSample.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { OpenTsdbQuery } from '../types'; + +import { DownSample, DownSampleProps, testIds } from './DownSample'; + +const onRunQuery = jest.fn(); +const onChange = jest.fn(); + +const tsdbVersions = [ + { label: '<=2.1', value: 1 }, + { label: '==2.2', value: 2 }, + { label: '==2.3', value: 3 }, +]; + +const setup = (tsdbVersion: number, propOverrides?: Object) => { + const query: OpenTsdbQuery = { + metric: '', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + }; + const props: DownSampleProps = { + query, + onChange: onChange, + onRunQuery: onRunQuery, + aggregators: ['avg'], + fillPolicies: ['none'], + tsdbVersion: tsdbVersion, + }; + + Object.assign(props, propOverrides); + + return render(); +}; +describe('DownSample', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render downsample section', () => { + setup(tsdbVersions[0].value); + expect(screen.getByTestId(testIds.section)).toBeInTheDocument(); + }); + + describe('downsample interval', () => { + it('should call runQuery on blur', () => { + setup(tsdbVersions[0].value); + fireEvent.click(screen.getByTestId('downsample-interval')); + fireEvent.blur(screen.getByTestId('downsample-interval')); + expect(onRunQuery).toHaveBeenCalled(); + }); + }); + + describe('aggregator select', () => { + it('should contain an aggregator', () => { + setup(tsdbVersions[0].value); + expect(screen.getByText('avg')).toBeInTheDocument(); + }); + }); + + describe('fillpolicies select', () => { + it('should contain an fillpolicy for versions >= 2.2', () => { + setup(tsdbVersions[1].value); + expect(screen.getByText('none')).toBeInTheDocument(); + }); + + it('does not display fill policy for version >= 2', () => { + setup(tsdbVersions[0].value); + expect(screen.queryByText('none')).toBeNull(); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/components/DownSample.tsx b/public/app/plugins/datasource/opentsdb/components/DownSample.tsx new file mode 100644 index 00000000000..ab022d66f48 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/DownSample.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import { toOption } from '@grafana/data'; +import { InlineLabel, Select, Input, InlineFormLabel, InlineSwitch } from '@grafana/ui'; + +import { OpenTsdbQuery } from '../types'; + +import { paddingRightClass } from './styles'; + +export interface DownSampleProps { + query: OpenTsdbQuery; + onChange: (query: OpenTsdbQuery) => void; + onRunQuery: () => void; + aggregators: string[]; + fillPolicies: string[]; + tsdbVersion: number; +} + +export function DownSample({ query, onChange, onRunQuery, aggregators, fillPolicies, tsdbVersion }: DownSampleProps) { + const aggregatorOptions = aggregators.map((value: string) => toOption(value)); + const fillPolicyOptions = fillPolicies.map((value: string) => toOption(value)); + + return ( +
+
+ + Leave interval blank for auto or for example use 1m +
+ } + > + Down sample + + { + const value = e.currentTarget.value; + onChange({ ...query, downsampleInterval: value }); + }} + onBlur={() => onRunQuery()} + /> +
+
+ + Aggregator + + { + if (value) { + onChange({ ...query, downsampleFillPolicy: value }); + onRunQuery(); + } + }} + /> +
+ )} +
+ Disable downsampling + { + const disableDownsampling = query.disableDownsampling ?? false; + onChange({ ...query, disableDownsampling: !disableDownsampling }); + onRunQuery(); + }} + /> +
+
+
+
+ + ); +} + +export const testIds = { + section: 'opentsdb-downsample', + interval: 'downsample-interval', +}; diff --git a/public/app/plugins/datasource/opentsdb/components/FilterSection.test.tsx b/public/app/plugins/datasource/opentsdb/components/FilterSection.test.tsx new file mode 100644 index 00000000000..b3c60167b93 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/FilterSection.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { OpenTsdbQuery } from '../types'; + +import { FilterSection, FilterSectionProps, testIds } from './FilterSection'; + +const onRunQuery = jest.fn(); +const onChange = jest.fn(); + +const setup = (propOverrides?: Object) => { + const suggestTagKeys = jest.fn(); + const suggestTagValues = jest.fn(); + + const query: OpenTsdbQuery = { + metric: 'cpu', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + filters: [ + { + filter: 'server1', + groupBy: true, + tagk: 'hostname', + type: 'iliteral_or', + }, + ], + }; + + const props: FilterSectionProps = { + query, + onChange: onChange, + onRunQuery: onRunQuery, + suggestTagKeys: suggestTagKeys, + filterTypes: ['literal_or'], + suggestTagValues: suggestTagValues, + }; + + Object.assign(props, propOverrides); + + return render(); +}; +describe('FilterSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render filter section', () => { + setup(); + expect(screen.getByTestId(testIds.section)).toBeInTheDocument(); + }); + + describe('filter editor', () => { + it('open the editor on clicking +', () => { + setup(); + fireEvent.click(screen.getByTestId(testIds.open)); + expect(screen.getByText('Group by')).toBeInTheDocument(); + }); + + it('should display a list of filters', () => { + setup(); + expect(screen.getByTestId(testIds.list + '0')).toBeInTheDocument(); + }); + + it('should call runQuery on adding a filter', () => { + setup(); + fireEvent.click(screen.getByTestId(testIds.open)); + fireEvent.click(screen.getByText('add filter')); + expect(onRunQuery).toHaveBeenCalled(); + }); + + it('should have an error if tags are present when adding a filter', () => { + const query: OpenTsdbQuery = { + metric: 'cpu', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + tags: [{}], + }; + setup({ query }); + fireEvent.click(screen.getByTestId(testIds.open)); + fireEvent.click(screen.getByText('add filter')); + expect(screen.getByTestId(testIds.error)).toBeInTheDocument(); + }); + + it('should remove a filter', () => { + const query: OpenTsdbQuery = { + metric: 'cpu', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + filters: [ + { + filter: 'server1', + groupBy: true, + tagk: 'hostname', + type: 'iliteral_or', + }, + ], + }; + + setup({ query }); + fireEvent.click(screen.getByTestId(testIds.remove)); + expect(query.filters?.length === 0).toBeTruthy(); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/components/FilterSection.tsx b/public/app/plugins/datasource/opentsdb/components/FilterSection.tsx new file mode 100644 index 00000000000..9bf1c4aa6b3 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/FilterSection.tsx @@ -0,0 +1,246 @@ +import { size } from 'lodash'; +import React, { useCallback, useState } from 'react'; + +import { SelectableValue, toOption } from '@grafana/data'; +import { InlineLabel, Select, InlineFormLabel, InlineSwitch, Icon } from '@grafana/ui'; + +import { OpenTsdbFilter, OpenTsdbQuery } from '../types'; + +export interface FilterSectionProps { + query: OpenTsdbQuery; + onChange: (query: OpenTsdbQuery) => void; + onRunQuery: () => void; + suggestTagKeys: (query: OpenTsdbQuery) => Promise; + filterTypes: string[]; + suggestTagValues: () => Promise; +} + +export function FilterSection({ + query, + onChange, + onRunQuery, + suggestTagKeys, + filterTypes, + suggestTagValues, +}: FilterSectionProps) { + const [tagKeys, updTagKeys] = useState>>(); + const [keyIsLoading, updKeyIsLoading] = useState(); + + const [tagValues, updTagValues] = useState>>(); + const [valueIsLoading, updValueIsLoading] = useState(); + + const [addFilterMode, updAddFilterMode] = useState(false); + + const [curFilterType, updCurFilterType] = useState('iliteral_or'); + const [curFilterKey, updCurFilterKey] = useState(''); + const [curFilterValue, updCurFilterValue] = useState(''); + const [curFilterGroupBy, updCurFilterGroupBy] = useState(false); + + const [errors, setErrors] = useState(''); + + const filterTypesOptions = filterTypes.map((value: string) => toOption(value)); + + function changeAddFilterMode() { + updAddFilterMode(!addFilterMode); + } + + function addFilter() { + if (query.tags && size(query.tags) > 0) { + const err = 'Please remove tags to use filters, tags and filters are mutually exclusive.'; + setErrors(err); + return; + } + + if (!addFilterMode) { + updAddFilterMode(true); + return; + } + + // Add the filter to the query + const currentFilter = { + type: curFilterType, + tagk: curFilterKey, + filter: curFilterValue, + groupBy: curFilterGroupBy, + }; + + // filters may be undefined + query.filters = query.filters ? query.filters.concat([currentFilter]) : [currentFilter]; + + // reset the inputs + updCurFilterType('literal_or'); + updCurFilterKey(''); + updCurFilterValue(''); + updCurFilterGroupBy(false); + + // fire the query + onChange(query); + onRunQuery(); + + // close the filter ditor + changeAddFilterMode(); + } + + function removeFilter(index: number) { + query.filters?.splice(index, 1); + // fire the query + onChange(query); + onRunQuery(); + } + + function editFilter(fil: OpenTsdbFilter, idx: number) { + removeFilter(idx); + updCurFilterKey(fil.tagk); + updCurFilterValue(fil.filter); + updCurFilterType(fil.type); + updCurFilterGroupBy(fil.groupBy); + addFilter(); + } + + // We are matching words split with space + const splitSeparator = ' '; + const customFilterOption = useCallback((option: SelectableValue, searchQuery: string) => { + const label = option.value ?? ''; + + const searchWords = searchQuery.split(splitSeparator); + return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true); + }, []); + + return ( +
+
+ Filters does not work with tags, either of the two will work but not both.
} + > + Filters + + {query.filters && + query.filters.map((fil: OpenTsdbFilter, idx: number) => { + return ( + + {fil.tagk} = {fil.type}({fil.filter}), groupBy = {'' + fil.groupBy} + editFilter(fil, idx)}> + + + removeFilter(idx)} data-testid={testIds.remove}> + + + + ); + })} + {!addFilterMode && ( + + )} +
+ {addFilterMode && ( +
+
+ { + if (value) { + updCurFilterType(value); + } + }} + /> +
+ +
+ { + if (!state.metrics) { + setState({ isLoading: true }); + const metrics = await suggestMetrics(); + setState({ metrics, isLoading: undefined }); + } + }} + isLoading={state.isLoading} + options={state.metrics} + onChange={({ value }) => { + if (value) { + onChange({ ...query, metric: value }); + onRunQuery(); + } + }} + /> +
+
+ + Aggregator + + { + const value = e.currentTarget.value; + onChange({ ...query, alias: value }); + }} + onBlur={() => onRunQuery()} + /> +
+
+
+
+
+ ); +} + +export const testIds = { + section: 'opentsdb-metricsection', + alias: 'metric-alias', +}; diff --git a/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.test.tsx b/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.test.tsx new file mode 100644 index 00000000000..ca0e6107130 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import OpenTsDatasource from '../datasource'; +import { OpenTsdbQuery } from '../types'; + +import { OpenTsdbQueryEditor, OpenTsdbQueryEditorProps, testIds } from './OpenTsdbQueryEditor'; + +const setup = (propOverrides?: Object) => { + const getAggregators = jest.fn().mockResolvedValue([]); + const getFilterTypes = jest.fn().mockResolvedValue([]); + + const datasourceMock: unknown = { + getAggregators, + getFilterTypes, + tsdbVersion: 1, + }; + + const datasource: OpenTsDatasource = datasourceMock as OpenTsDatasource; + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + const query: OpenTsdbQuery = { metric: '', refId: 'A' }; + const props: OpenTsdbQueryEditorProps = { + datasource: datasource, + onRunQuery: onRunQuery, + onChange: onChange, + query, + }; + + Object.assign(props, propOverrides); + + return render(); +}; +describe('OpenTsdbQueryEditor', () => { + it('should render editor', () => { + setup(); + expect(screen.getByTestId(testIds.editor)).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.tsx b/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.tsx new file mode 100644 index 00000000000..cfb504f9946 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.tsx @@ -0,0 +1,160 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2, QueryEditorProps, textUtil } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import OpenTsDatasource from '../datasource'; +import { OpenTsdbOptions, OpenTsdbQuery } from '../types'; + +import { DownSample } from './DownSample'; +import { FilterSection } from './FilterSection'; +import { MetricSection } from './MetricSection'; +import { RateSection } from './RateSection'; +import { TagSection } from './TagSection'; + +export type OpenTsdbQueryEditorProps = QueryEditorProps; + +export function OpenTsdbQueryEditor({ + datasource, + onRunQuery, + onChange, + query, + range, + queries, +}: OpenTsdbQueryEditorProps) { + const styles = useStyles2(getStyles); + + const [aggregators, setAggregators] = useState([ + 'avg', + 'sum', + 'min', + 'max', + 'dev', + 'zimsum', + 'mimmin', + 'mimmax', + ]); + + const fillPolicies: string[] = ['none', 'nan', 'null', 'zero']; + + const [filterTypes, setFilterTypes] = useState([ + 'wildcard', + 'iliteral_or', + 'not_iliteral_or', + 'not_literal_or', + 'iwildcard', + 'literal_or', + 'regexp', + ]); + + const tsdbVersion: number = datasource.tsdbVersion; + + if (!query.aggregator) { + query.aggregator = 'sum'; + } + + if (!query.downsampleAggregator) { + query.downsampleAggregator = 'avg'; + } + + if (!query.downsampleFillPolicy) { + query.downsampleFillPolicy = 'none'; + } + + datasource.getAggregators().then((aggs: string[]) => { + if (aggs.length !== 0) { + setAggregators(aggs); + } + }); + + datasource.getFilterTypes().then((filterTypes: string[]) => { + if (filterTypes.length !== 0) { + setFilterTypes(filterTypes); + } + }); + + // previously called as an autocomplete on every input, + // in this we call it once on init and filter in the MetricSection component + async function suggestMetrics(): Promise> { + return datasource.metricFindQuery('metrics()').then(getTextValues); + } + + // previously called as an autocomplete on every input, + // in this we call it once on init and filter in the MetricSection component + async function suggestTagValues(): Promise> { + return datasource.metricFindQuery('suggest_tagv()').then(getTextValues); + } + + async function suggestTagKeys(query: OpenTsdbQuery): Promise { + return datasource.suggestTagKeys(query); + } + + function getTextValues(metrics: Array<{ text: string }>) { + return metrics.map((value: { text: string }) => { + return { + value: textUtil.escapeHtml(value.text), + description: value.text, + }; + }); + } + + return ( +
+
+ + + {tsdbVersion >= 2 && ( + + )} + + +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css` + display: flex; + `, + visualEditor: css` + flex-grow: 1; + `, + toggleButton: css` + margin-left: ${theme.spacing(0.5)}; + `, + }; +} + +export const testIds = { + editor: 'opentsdb-editor', +}; diff --git a/public/app/plugins/datasource/opentsdb/components/RateSection.test.tsx b/public/app/plugins/datasource/opentsdb/components/RateSection.test.tsx new file mode 100644 index 00000000000..a5b8bf07ee0 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/RateSection.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { OpenTsdbQuery } from '../types'; + +import { RateSection, RateSectionProps, testIds } from './RateSection'; + +const onRunQuery = jest.fn(); +const onChange = jest.fn(); + +const tsdbVersions = [ + { label: '<=2.1', value: 1 }, + { label: '==2.2', value: 2 }, + { label: '==2.3', value: 3 }, +]; + +const setup = (tsdbVersion: number, propOverrides?: Object) => { + const query: OpenTsdbQuery = { + metric: '', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + }; + const props: RateSectionProps = { + query, + onChange: onChange, + onRunQuery: onRunQuery, + tsdbVersion: tsdbVersion, + }; + + Object.assign(props, propOverrides); + + return render(); +}; +describe('RateSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the rate section', () => { + setup(tsdbVersions[0].value); + expect(screen.getByTestId(testIds.section)).toBeInTheDocument(); + }); + + describe('rate components', () => { + it('should render the counter switch when rate is switched on', () => { + setup(tsdbVersions[0].value, { query: { shouldComputeRate: true } }); + expect(screen.getByTestId(testIds.isCounter)).toBeInTheDocument(); + }); + + it('should render the max count input when rate & counter are switched on', () => { + setup(tsdbVersions[0].value, { query: { shouldComputeRate: true, isCounter: true } }); + expect(screen.getByTestId(testIds.counterMax)).toBeInTheDocument(); + }); + }); + + describe('explicit tags', () => { + it('should render explicit tags switch for tsdb versions > 2.2', () => { + setup(tsdbVersions[2].value); + expect(screen.getByText('Explicit tags')).toBeInTheDocument(); + }); + + it('should not render explicit tags switch for tsdb versions <= 2.2', () => { + setup(tsdbVersions[0].value); + expect(screen.queryByText('Explicit tags')).toBeNull(); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/components/RateSection.tsx b/public/app/plugins/datasource/opentsdb/components/RateSection.tsx new file mode 100644 index 00000000000..868969b39b3 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/RateSection.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +import { InlineLabel, Input, InlineFormLabel, InlineSwitch } from '@grafana/ui'; + +import { OpenTsdbQuery } from '../types'; + +export interface RateSectionProps { + query: OpenTsdbQuery; + onChange: (query: OpenTsdbQuery) => void; + onRunQuery: () => void; + tsdbVersion: number; +} + +export function RateSection({ query, onChange, onRunQuery, tsdbVersion }: RateSectionProps) { + return ( +
+
+ + Rate + + { + const shouldComputeRate = query.shouldComputeRate ?? false; + onChange({ ...query, shouldComputeRate: !shouldComputeRate }); + onRunQuery(); + }} + /> +
+ {query.shouldComputeRate && ( +
+ + Counter + + { + const isCounter = query.isCounter ?? false; + onChange({ ...query, isCounter: !isCounter }); + onRunQuery(); + }} + /> +
+ )} + {query.shouldComputeRate && query.isCounter && ( +
+ + Counter max + + { + const value = e.currentTarget.value; + onChange({ ...query, counterMax: value }); + }} + onBlur={() => onRunQuery()} + /> + + Reset value + + { + const value = e.currentTarget.value; + onChange({ ...query, counterResetValue: value }); + }} + onBlur={() => onRunQuery()} + /> +
+ )} + {tsdbVersion > 2 && ( +
+ + Explicit tags + + { + const explicitTags = query.explicitTags ?? false; + onChange({ ...query, explicitTags: !explicitTags }); + onRunQuery(); + }} + /> +
+ )} +
+
+
+
+ ); +} + +export const testIds = { + section: 'opentsdb-rate', + shouldComputeRate: 'opentsdb-shouldComputeRate', + isCounter: 'opentsdb-is-counter', + counterMax: 'opentsdb-counter-max', + counterResetValue: 'opentsdb-counter-reset-value', + explicitTags: 'opentsdb-explicit-tags', +}; diff --git a/public/app/plugins/datasource/opentsdb/components/TagSection.test.tsx b/public/app/plugins/datasource/opentsdb/components/TagSection.test.tsx new file mode 100644 index 00000000000..e399e52c8c2 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/TagSection.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { OpenTsdbQuery } from '../types'; + +import { TagSection, TagSectionProps, testIds } from './TagSection'; + +const onRunQuery = jest.fn(); +const onChange = jest.fn(); + +const setup = (propOverrides?: Object) => { + const suggestTagKeys = jest.fn(); + const suggestTagValues = jest.fn(); + + const query: OpenTsdbQuery = { + metric: 'cpu', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + tags: { + tagKey: 'tagValue', + }, + }; + + const props: TagSectionProps = { + query, + onChange: onChange, + onRunQuery: onRunQuery, + suggestTagKeys: suggestTagKeys, + suggestTagValues: suggestTagValues, + tsdbVersion: 2, + }; + + Object.assign(props, propOverrides); + + return render(); +}; +describe('Tag Section', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render tag section', () => { + setup(); + expect(screen.getByTestId(testIds.section)).toBeInTheDocument(); + }); + + describe('tag editor', () => { + it('open the editor on clicking +', () => { + setup(); + fireEvent.click(screen.getByTestId(testIds.open)); + expect(screen.getByText('add tag')).toBeInTheDocument(); + }); + + it('should display a list of tags', () => { + setup(); + expect(screen.getByTestId(testIds.list + '0')).toBeInTheDocument(); + }); + + it('should call runQuery on adding a tag', () => { + setup(); + fireEvent.click(screen.getByTestId(testIds.open)); + fireEvent.click(screen.getByText('add tag')); + expect(onRunQuery).toHaveBeenCalled(); + }); + + it('should have an error if filters are present when adding a tag', () => { + const query: OpenTsdbQuery = { + metric: 'cpu', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + filters: [ + { + filter: 'server1', + groupBy: true, + tagk: 'hostname', + type: 'iliteral_or', + }, + ], + }; + setup({ query }); + fireEvent.click(screen.getByTestId(testIds.open)); + fireEvent.click(screen.getByText('add tag')); + expect(screen.getByTestId(testIds.error)).toBeInTheDocument(); + }); + + it('should remove a tag', () => { + const query: OpenTsdbQuery = { + metric: 'cpu', + refId: 'A', + downsampleAggregator: 'avg', + downsampleFillPolicy: 'none', + tags: { + tag: 'tagToRemove', + }, + }; + + setup({ query }); + fireEvent.click(screen.getByTestId(testIds.remove)); + expect(Object.keys(query.tags).length === 0).toBeTruthy(); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/components/TagSection.tsx b/public/app/plugins/datasource/opentsdb/components/TagSection.tsx new file mode 100644 index 00000000000..563cdbe8764 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/TagSection.tsx @@ -0,0 +1,219 @@ +import { has, size } from 'lodash'; +import React, { useCallback, useState } from 'react'; + +import { SelectableValue, toOption } from '@grafana/data'; +import { Select, InlineFormLabel, Icon } from '@grafana/ui'; + +import { OpenTsdbQuery } from '../types'; + +export interface TagSectionProps { + query: OpenTsdbQuery; + onChange: (query: OpenTsdbQuery) => void; + onRunQuery: () => void; + suggestTagKeys: (query: OpenTsdbQuery) => Promise; + suggestTagValues: () => Promise; + tsdbVersion: number; +} + +export function TagSection({ + query, + onChange, + onRunQuery, + suggestTagKeys, + suggestTagValues, + tsdbVersion, +}: TagSectionProps) { + const [tagKeys, updTagKeys] = useState>>(); + const [keyIsLoading, updKeyIsLoading] = useState(); + + const [tagValues, updTagValues] = useState>>(); + const [valueIsLoading, updValueIsLoading] = useState(); + + const [addTagMode, updAddTagMode] = useState(false); + + const [curTagKey, updCurTagKey] = useState(''); + const [curTagValue, updCurTagValue] = useState(''); + + const [errors, setErrors] = useState(''); + + function changeAddTagMode() { + updAddTagMode(!addTagMode); + } + + function addTag() { + if (query.filters && size(query.filters) > 0) { + const err = 'Please remove filters to use tags, tags and filters are mutually exclusive.'; + setErrors(err); + return; + } + + if (!addTagMode) { + updAddTagMode(true); + return; + } + + // check for duplicate tags + if (query.tags && has(query.tags, curTagKey)) { + const err = "Duplicate tag key '" + curTagKey + "'."; + setErrors(err); + return; + } + + // tags may be undefined + if (!query.tags) { + query.tags = {}; + } + + // add tag to query + query.tags[curTagKey] = curTagValue; + + // reset the inputs + updCurTagKey(''); + updCurTagValue(''); + + // fire the query + onChange(query); + onRunQuery(); + + // close the tag ditor + changeAddTagMode(); + } + + function removeTag(key: string | number) { + delete query.tags[key]; + + // fire off the query + onChange(query); + onRunQuery(); + } + + function editTag(key: string | number, value: string) { + removeTag(key); + updCurTagKey(key); + updCurTagValue(value); + addTag(); + } + + // We are matching words split with space + const splitSeparator = ' '; + const customTagOption = useCallback((option: SelectableValue, searchQuery: string) => { + const label = option.value ?? ''; + + const searchWords = searchQuery.split(splitSeparator); + return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true); + }, []); + + return ( +
+
+ = 2 ?
Please use filters, tags are deprecated in opentsdb 2.2
: undefined} + > + Tags +
+ {query.tags && + Object.keys(query.tags).map((tagKey: string | number, idx: number) => { + const tagValue = query.tags[tagKey]; + return ( + + {tagKey}={tagValue} + editTag(tagKey, tagValue)}> + + + removeTag(tagKey)} data-testid={testIds.remove}> + + + + ); + })} + {!addTagMode && ( + + )} +
+ {addTagMode && ( +
+
+ { + if (!tagValues) { + updValueIsLoading(true); + const tVs = await suggestTagValues(); + updTagValues(tVs); + updValueIsLoading(false); + } + }} + isLoading={valueIsLoading} + options={tagValues} + onChange={({ value }) => { + if (value) { + updCurTagValue(value); + } + }} + /> +
+ +
+ {errors && ( + + )} + + +
+
+ )} +
+
+
+
+ ); +} + +export const testIds = { + section: 'opentsdb-tag', + open: 'opentsdb-tag-editor', + list: 'opentsdb-tag-list', + error: 'opentsdb-tag-error', + remove: 'opentsdb-tag-remove', +}; diff --git a/public/app/plugins/datasource/opentsdb/components/styles.ts b/public/app/plugins/datasource/opentsdb/components/styles.ts new file mode 100644 index 00000000000..c042d110665 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/styles.ts @@ -0,0 +1,5 @@ +import { css } from '@emotion/css'; + +export const paddingRightClass = css({ + paddingRight: '4px', +}); diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts index f05d97b52e7..95d43902b71 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.ts +++ b/public/app/plugins/datasource/opentsdb/datasource.ts @@ -1,6 +1,6 @@ -import angular from 'angular'; import { clone, + cloneDeep, compact, each, every, @@ -242,7 +242,8 @@ export default class OpenTsDatasource extends DataSourceApi 0) { - query.filters = angular.copy(target.filters); + query.filters = cloneDeep(target.filters); + if (query.filters) { for (const filterKey in query.filters) { query.filters[filterKey].filter = this.templateSrv.replace( @@ -548,7 +550,8 @@ export default class OpenTsDatasource extends DataSourceApi -
-
- - - -
-
- -
- -
-
-
- - -
- -
-
-
-
- -
-
- - - - blank for auto, or for example 1m - -
- -
- -
- -
-
- -
- -
- -
-
- - - - -
-
-
-
- -
-
- - - -
- {{fil.tagk}} = {{fil.type}}({{fil.filter}}) , groupBy = {{fil.groupBy}} - - - - - - -
- -
- -
-
- - -
- -
- -
- -
-
- -
- - -
- - - - -
- - -
- -
- -
-
-
-
- -
-
- -
- -
- -
- -
- -
- -
- - - - - - - - -
- -
-
-
-
- -
- - - - - - - -
- - - - - - - -
- -
- - -
- -
-
-
-
- - diff --git a/public/app/plugins/datasource/opentsdb/query_ctrl.ts b/public/app/plugins/datasource/opentsdb/query_ctrl.ts deleted file mode 100644 index 7e71745f7c9..00000000000 --- a/public/app/plugins/datasource/opentsdb/query_ctrl.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { auto } from 'angular'; -import { map, size, has } from 'lodash'; - -import { textUtil, rangeUtil } from '@grafana/data'; -import { QueryCtrl } from 'app/plugins/sdk'; - -export class OpenTsQueryCtrl extends QueryCtrl { - static templateUrl = 'partials/query.editor.html'; - aggregators: any; - fillPolicies: any; - filterTypes: any; - tsdbVersion: any; - aggregator: any; - downsampleInterval: any; - downsampleAggregator: any; - downsampleFillPolicy: any; - errors: any; - suggestMetrics: any; - suggestTagKeys: any; - suggestTagValues: any; - addTagMode = false; - addFilterMode = false; - - /** @ngInject */ - constructor($scope: any, $injector: auto.IInjectorService) { - super($scope, $injector); - - this.errors = this.validateTarget(); - this.aggregators = ['avg', 'sum', 'min', 'max', 'dev', 'zimsum', 'mimmin', 'mimmax']; - this.fillPolicies = ['none', 'nan', 'null', 'zero']; - this.filterTypes = [ - 'wildcard', - 'iliteral_or', - 'not_iliteral_or', - 'not_literal_or', - 'iwildcard', - 'literal_or', - 'regexp', - ]; - - this.tsdbVersion = this.datasource.tsdbVersion; - - if (!this.target.aggregator) { - this.target.aggregator = 'sum'; - } - - if (!this.target.downsampleAggregator) { - this.target.downsampleAggregator = 'avg'; - } - - if (!this.target.downsampleFillPolicy) { - this.target.downsampleFillPolicy = 'none'; - } - - this.datasource.getAggregators().then((aggs: { length: number }) => { - if (aggs.length !== 0) { - this.aggregators = aggs; - } - }); - - this.datasource.getFilterTypes().then((filterTypes: { length: number }) => { - if (filterTypes.length !== 0) { - this.filterTypes = filterTypes; - } - }); - - // needs to be defined here as it is called from typeahead - this.suggestMetrics = (query: string, callback: any) => { - this.datasource - .metricFindQuery('metrics(' + query + ')') - .then(this.getTextValues) - .then(callback); - }; - - this.suggestTagKeys = (query: any, callback: any) => { - this.datasource.suggestTagKeys(this.target.metric).then(callback); - }; - - this.suggestTagValues = (query: string, callback: any) => { - this.datasource - .metricFindQuery('suggest_tagv(' + query + ')') - .then(this.getTextValues) - .then(callback); - }; - } - - targetBlur() { - this.errors = this.validateTarget(); - this.refresh(); - } - - getTextValues(metricFindResult: any) { - return map(metricFindResult, (value) => { - return textUtil.escapeHtml(value.text); - }); - } - - addTag() { - if (this.target.filters && this.target.filters.length > 0) { - this.errors.tags = 'Please remove filters to use tags, tags and filters are mutually exclusive.'; - } - - if (!this.addTagMode) { - this.addTagMode = true; - return; - } - - if (!this.target.tags) { - this.target.tags = {}; - } - - this.errors = this.validateTarget(); - - if (!this.errors.tags) { - this.target.tags[this.target.currentTagKey] = this.target.currentTagValue; - this.target.currentTagKey = ''; - this.target.currentTagValue = ''; - this.targetBlur(); - } - - this.addTagMode = false; - } - - removeTag(key: string | number) { - delete this.target.tags[key]; - this.targetBlur(); - } - - editTag(key: string | number, value: any) { - this.removeTag(key); - this.target.currentTagKey = key; - this.target.currentTagValue = value; - this.addTag(); - } - - closeAddTagMode() { - this.addTagMode = false; - return; - } - - addFilter() { - if (this.target.tags && size(this.target.tags) > 0) { - this.errors.filters = 'Please remove tags to use filters, tags and filters are mutually exclusive.'; - } - - if (!this.addFilterMode) { - this.addFilterMode = true; - return; - } - - if (!this.target.filters) { - this.target.filters = []; - } - - if (!this.target.currentFilterType) { - this.target.currentFilterType = 'iliteral_or'; - } - - if (!this.target.currentFilterGroupBy) { - this.target.currentFilterGroupBy = false; - } - - this.errors = this.validateTarget(); - - if (!this.errors.filters) { - const currentFilter = { - type: this.target.currentFilterType, - tagk: this.target.currentFilterKey, - filter: this.target.currentFilterValue, - groupBy: this.target.currentFilterGroupBy, - }; - this.target.filters.push(currentFilter); - this.target.currentFilterType = 'literal_or'; - this.target.currentFilterKey = ''; - this.target.currentFilterValue = ''; - this.target.currentFilterGroupBy = false; - this.targetBlur(); - } - - this.addFilterMode = false; - } - - removeFilter(index: number) { - this.target.filters.splice(index, 1); - this.targetBlur(); - } - - editFilter(fil: { tagk: any; filter: any; type: any; groupBy: any }, index: number) { - this.removeFilter(index); - this.target.currentFilterKey = fil.tagk; - this.target.currentFilterValue = fil.filter; - this.target.currentFilterType = fil.type; - this.target.currentFilterGroupBy = fil.groupBy; - this.addFilter(); - } - - closeAddFilterMode() { - this.addFilterMode = false; - return; - } - - validateTarget() { - const errs: any = {}; - - if (this.target.shouldDownsample) { - try { - if (this.target.downsampleInterval) { - rangeUtil.describeInterval(this.target.downsampleInterval); - } else { - errs.downsampleInterval = "You must supply a downsample interval (e.g. '1m' or '1h')."; - } - } catch (err) { - if (err instanceof Error) { - errs.downsampleInterval = err.message; - } - } - } - - if (this.target.tags && has(this.target.tags, this.target.currentTagKey)) { - errs.tags = "Duplicate tag key '" + this.target.currentTagKey + "'."; - } - - return errs; - } -} diff --git a/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts b/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts deleted file mode 100644 index 17d0d48f11d..00000000000 --- a/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { OpenTsQueryCtrl } from '../query_ctrl'; - -describe('OpenTsQueryCtrl', () => { - const ctx = { - target: { target: '' }, - datasource: { - tsdbVersion: '', - getAggregators: () => Promise.resolve([]), - getFilterTypes: () => Promise.resolve([]), - }, - } as any; - - ctx.panelCtrl = { - panel: { - targets: [ctx.target], - }, - refresh: () => {}, - }; - - Object.assign(OpenTsQueryCtrl.prototype, ctx); - - beforeEach(() => { - ctx.ctrl = new OpenTsQueryCtrl({}, {} as any); - }); - - describe('init query_ctrl variables', () => { - it('filter types should be initialized', () => { - expect(ctx.ctrl.filterTypes.length).toBe(7); - }); - - it('aggregators should be initialized', () => { - expect(ctx.ctrl.aggregators.length).toBe(8); - }); - - it('fill policy options should be initialized', () => { - expect(ctx.ctrl.fillPolicies.length).toBe(4); - }); - }); - - describe('when adding filters and tags', () => { - it('addTagMode should be false when closed', () => { - ctx.ctrl.addTagMode = true; - ctx.ctrl.closeAddTagMode(); - expect(ctx.ctrl.addTagMode).toBe(false); - }); - - it('addFilterMode should be false when closed', () => { - ctx.ctrl.addFilterMode = true; - ctx.ctrl.closeAddFilterMode(); - expect(ctx.ctrl.addFilterMode).toBe(false); - }); - - it('removing a tag from the tags list', () => { - ctx.ctrl.target.tags = { tagk: 'tag_key', tagk2: 'tag_value2' }; - ctx.ctrl.removeTag('tagk'); - expect(Object.keys(ctx.ctrl.target.tags).length).toBe(1); - }); - - it('removing a filter from the filters list', () => { - ctx.ctrl.target.filters = [ - { - tagk: 'tag_key', - filter: 'tag_value2', - type: 'wildcard', - groupBy: true, - }, - ]; - ctx.ctrl.removeFilter(0); - expect(ctx.ctrl.target.filters.length).toBe(0); - }); - - it('adding a filter when tags exist should generate error', () => { - ctx.ctrl.target.tags = { tagk: 'tag_key', tagk2: 'tag_value2' }; - ctx.ctrl.addFilter(); - expect(ctx.ctrl.errors.filters).toBe( - 'Please remove tags to use filters, tags and filters are mutually exclusive.' - ); - }); - - it('adding a tag when filters exist should generate error', () => { - ctx.ctrl.target.filters = [ - { - tagk: 'tag_key', - filter: 'tag_value2', - type: 'wildcard', - groupBy: true, - }, - ]; - ctx.ctrl.addTag(); - expect(ctx.ctrl.errors.tags).toBe('Please remove filters to use tags, tags and filters are mutually exclusive.'); - }); - }); -}); diff --git a/public/app/plugins/datasource/opentsdb/types.ts b/public/app/plugins/datasource/opentsdb/types.ts index 909feeb9cbe..4ae66b8d6d1 100644 --- a/public/app/plugins/datasource/opentsdb/types.ts +++ b/public/app/plugins/datasource/opentsdb/types.ts @@ -1,12 +1,36 @@ import { DataQuery, DataSourceJsonData } from '@grafana/data'; export interface OpenTsdbQuery extends DataQuery { - metric?: any; + // migrating to react + // metrics section + metric?: string; + aggregator?: string; + alias?: string; + + //downsample section + downsampleInterval?: string; + downsampleAggregator?: string; + downsampleFillPolicy?: string; + disableDownsampling?: boolean; + + //filters + filters?: OpenTsdbFilter[]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tags?: any; + // annotation attrs fromAnnotations?: boolean; isGlobal?: boolean; target?: string; name?: string; + + // rate + shouldComputeRate?: boolean; + isCounter?: boolean; + counterMax?: string; + counterResetValue?: string; + explicitTags?: boolean; } export interface OpenTsdbOptions extends DataSourceJsonData { @@ -21,3 +45,10 @@ export type LegacyAnnotation = { target?: string; name?: string; }; + +export type OpenTsdbFilter = { + type: string; + tagk: string; + filter: string; + groupBy: boolean; +};