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
This commit is contained in:
Brendan O'Handley 2022-10-03 17:00:46 -04:00 committed by GitHub
parent ad48cee2bb
commit 82d8015469
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1451 additions and 622 deletions

View File

@ -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"],

View File

@ -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(<DownSample {...props} />);
};
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();
});
});
});

View File

@ -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 (
<div className="gf-form-inline" data-testid={testIds.section}>
<div className="gf-form">
<InlineFormLabel
className="query-keyword"
width={8}
tooltip={
<div>
Leave interval blank for auto or for example use <code>1m</code>
</div>
}
>
Down sample
</InlineFormLabel>
<Input
width={25}
className={paddingRightClass}
data-testid={testIds.interval}
placeholder="interval"
value={query.downsampleInterval ?? ''}
onChange={(e) => {
const value = e.currentTarget.value;
onChange({ ...query, downsampleInterval: value });
}}
onBlur={() => onRunQuery()}
/>
</div>
<div className="gf-form">
<InlineFormLabel width={'auto'} className="query-keyword">
Aggregator
</InlineFormLabel>
<Select
className="gf-form-input"
value={query.downsampleAggregator ? toOption(query.downsampleAggregator) : undefined}
options={aggregatorOptions}
onChange={({ value }) => {
if (value) {
onChange({ ...query, downsampleAggregator: value });
onRunQuery();
}
}}
/>
</div>
{tsdbVersion >= 2 && (
<div className="gf-form">
<InlineLabel className="width-6 query-keyword">Fill</InlineLabel>
<Select
inputId="opentsdb-fillpolicy-select"
value={query.downsampleFillPolicy ? toOption(query.downsampleFillPolicy) : undefined}
options={fillPolicyOptions}
onChange={({ value }) => {
if (value) {
onChange({ ...query, downsampleFillPolicy: value });
onRunQuery();
}
}}
/>
</div>
)}
<div className="gf-form">
<InlineFormLabel className="query-keyword">Disable downsampling</InlineFormLabel>
<InlineSwitch
value={query.disableDownsampling ?? false}
onChange={() => {
const disableDownsampling = query.disableDownsampling ?? false;
onChange({ ...query, disableDownsampling: !disableDownsampling });
onRunQuery();
}}
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow"></div>
</div>
</div>
);
}
export const testIds = {
section: 'opentsdb-downsample',
interval: 'downsample-interval',
};

View File

@ -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(<FilterSection {...props} />);
};
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();
});
});
});

View File

@ -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<string[]>;
filterTypes: string[];
suggestTagValues: () => Promise<SelectableValue[]>;
}
export function FilterSection({
query,
onChange,
onRunQuery,
suggestTagKeys,
filterTypes,
suggestTagValues,
}: FilterSectionProps) {
const [tagKeys, updTagKeys] = useState<Array<SelectableValue<string>>>();
const [keyIsLoading, updKeyIsLoading] = useState<boolean>();
const [tagValues, updTagValues] = useState<Array<SelectableValue<string>>>();
const [valueIsLoading, updValueIsLoading] = useState<boolean>();
const [addFilterMode, updAddFilterMode] = useState<boolean>(false);
const [curFilterType, updCurFilterType] = useState<string>('iliteral_or');
const [curFilterKey, updCurFilterKey] = useState<string>('');
const [curFilterValue, updCurFilterValue] = useState<string>('');
const [curFilterGroupBy, updCurFilterGroupBy] = useState<boolean>(false);
const [errors, setErrors] = useState<string>('');
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<string>, searchQuery: string) => {
const label = option.value ?? '';
const searchWords = searchQuery.split(splitSeparator);
return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true);
}, []);
return (
<div className="gf-form-inline" data-testid={testIds.section}>
<div className="gf-form">
<InlineFormLabel
className="query-keyword"
width={8}
tooltip={<div>Filters does not work with tags, either of the two will work but not both.</div>}
>
Filters
</InlineFormLabel>
{query.filters &&
query.filters.map((fil: OpenTsdbFilter, idx: number) => {
return (
<InlineFormLabel key={idx} width="auto" data-testid={testIds.list + idx}>
{fil.tagk} = {fil.type}({fil.filter}), groupBy = {'' + fil.groupBy}
<a onClick={() => editFilter(fil, idx)}>
<Icon name={'pen'} />
</a>
<a onClick={() => removeFilter(idx)} data-testid={testIds.remove}>
<Icon name={'times'} />
</a>
</InlineFormLabel>
);
})}
{!addFilterMode && (
<label className="gf-form-label query-keyword">
<a onClick={changeAddFilterMode} data-testid={testIds.open}>
<Icon name={'plus'} />
</a>
</label>
)}
</div>
{addFilterMode && (
<div className="gf-form-inline">
<div className="gf-form">
<Select
inputId="opentsdb-suggested-tagk-select"
className="gf-form-input"
value={curFilterKey ? toOption(curFilterKey) : undefined}
placeholder="key"
onOpenMenu={async () => {
updKeyIsLoading(true);
const tKs = await suggestTagKeys(query);
const tKsOptions = tKs.map((value: string) => toOption(value));
updTagKeys(tKsOptions);
updKeyIsLoading(false);
}}
isLoading={keyIsLoading}
options={tagKeys}
onChange={({ value }) => {
if (value) {
updCurFilterKey(value);
}
}}
/>
</div>
<div className="gf-form">
<InlineLabel className="width-4 query-keyword">Type</InlineLabel>
<Select
inputId="opentsdb-aggregator-select"
value={curFilterType ? toOption(curFilterType) : undefined}
options={filterTypesOptions}
onChange={({ value }) => {
if (value) {
updCurFilterType(value);
}
}}
/>
</div>
<div className="gf-form">
<Select
inputId="opentsdb-suggested-tagv-select"
className="gf-form-input"
value={curFilterValue ? toOption(curFilterValue) : undefined}
placeholder="filter"
allowCustomValue
filterOption={customFilterOption}
onOpenMenu={async () => {
if (!tagValues) {
updValueIsLoading(true);
const tVs = await suggestTagValues();
updTagValues(tVs);
updValueIsLoading(false);
}
}}
isLoading={valueIsLoading}
options={tagValues}
onChange={({ value }) => {
if (value) {
updCurFilterValue(value);
}
}}
/>
</div>
<InlineFormLabel width={5} className="query-keyword">
Group by
</InlineFormLabel>
<InlineSwitch
value={curFilterGroupBy}
onChange={() => {
// DO NOT RUN THE QUERY HERE
// OLD FUNCTIONALITY RAN THE QUERY
updCurFilterGroupBy(!curFilterGroupBy);
}}
/>
<div className="gf-form">
{errors && (
<label className="gf-form-label" title={errors} data-testid={testIds.error}>
<Icon name={'exclamation-triangle'} color={'rgb(229, 189, 28)'} />
</label>
)}
<label className="gf-form-label">
<a onClick={addFilter}>add filter</a>
<a onClick={changeAddFilterMode}>
<Icon name={'times'} />
</a>
</label>
</div>
</div>
)}
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow"></div>
</div>
</div>
);
}
export const testIds = {
section: 'opentsdb-filter',
open: 'opentsdb-filter-editor',
list: 'opentsdb-filter-list',
error: 'opentsdb-filter-error',
remove: 'opentsdb-filter-remove',
};

View File

@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { OpenTsdbQuery } from '../types';
import { MetricSection, MetricSectionProps, testIds } from './MetricSection';
const onRunQuery = jest.fn();
const onChange = jest.fn();
const setup = (propOverrides?: Object) => {
const suggestMetrics = jest.fn();
const query: OpenTsdbQuery = {
metric: 'cpu',
refId: 'A',
aggregator: 'avg',
alias: 'alias',
};
const props: MetricSectionProps = {
query,
onChange: onChange,
onRunQuery: onRunQuery,
suggestMetrics: suggestMetrics,
aggregators: ['avg'],
};
Object.assign(props, propOverrides);
return render(<MetricSection {...props} />);
};
describe('MetricSection', () => {
it('should render metrics section', () => {
setup();
expect(screen.getByTestId(testIds.section)).toBeInTheDocument();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('metric aggregator', () => {
it('should render metrics select', () => {
setup();
expect(screen.getByText('cpu')).toBeInTheDocument();
});
});
describe('metric aggregator', () => {
it('should render the metrics aggregator', () => {
setup();
expect(screen.getByText('avg')).toBeInTheDocument();
});
});
describe('metric alias', () => {
it('should render the alias input', () => {
setup();
expect(screen.getByTestId('metric-alias')).toBeInTheDocument();
});
it('should fire OnRunQuery on blur', () => {
setup();
const alias = screen.getByTestId('metric-alias');
fireEvent.click(alias);
fireEvent.blur(alias);
expect(onRunQuery).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,110 @@
import React, { useCallback, useState } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { Select, Input, InlineFormLabel } from '@grafana/ui';
import { OpenTsdbQuery } from '../types';
export interface MetricSectionProps {
query: OpenTsdbQuery;
onChange: (query: OpenTsdbQuery) => void;
onRunQuery: () => void;
suggestMetrics: () => Promise<SelectableValue[]>;
aggregators: string[];
}
export function MetricSection({ query, onChange, onRunQuery, suggestMetrics, aggregators }: MetricSectionProps) {
const [state, setState] = useState<{
metrics?: Array<SelectableValue<string>>;
isLoading?: boolean;
}>({});
// We are matching words split with space
const splitSeparator = ' ';
const customFilterOption = useCallback((option: SelectableValue<string>, searchQuery: string) => {
const label = option.value ?? '';
const searchWords = searchQuery.split(splitSeparator);
return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true);
}, []);
const aggregatorOptions = aggregators.map((value: string) => toOption(value));
return (
<div className="gf-form-inline" data-testid={testIds.section}>
<div className="gf-form">
<InlineFormLabel width={8} className="query-keyword">
Metric
</InlineFormLabel>
<Select
width={25}
inputId="opentsdb-metric-select"
className="gf-form-input"
value={query.metric ? toOption(query.metric) : undefined}
placeholder="Metric name"
allowCustomValue
filterOption={customFilterOption}
onOpenMenu={async () => {
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();
}
}}
/>
</div>
<div className="gf-form">
<InlineFormLabel width={'auto'} className="query-keyword">
Aggregator
</InlineFormLabel>
<Select
inputId="opentsdb-aggregator-select"
className="gf-form-input"
value={query.aggregator ? toOption(query.aggregator) : undefined}
options={aggregatorOptions}
onChange={({ value }) => {
if (value) {
onChange({ ...query, aggregator: value });
onRunQuery();
}
}}
/>
</div>
<div className="gf-form max-width-20">
<InlineFormLabel
className="query-keyword"
width={6}
tooltip={<div>Use patterns like $tag_tagname to replace part of the alias for a tag value</div>}
>
Alias
</InlineFormLabel>
<Input
data-testid={testIds.alias}
placeholder="series alias"
value={query.alias ?? ''}
onChange={(e) => {
const value = e.currentTarget.value;
onChange({ ...query, alias: value });
}}
onBlur={() => onRunQuery()}
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow"></div>
</div>
</div>
);
}
export const testIds = {
section: 'opentsdb-metricsection',
alias: 'metric-alias',
};

View File

@ -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(<OpenTsdbQueryEditor {...props} />);
};
describe('OpenTsdbQueryEditor', () => {
it('should render editor', () => {
setup();
expect(screen.getByTestId(testIds.editor)).toBeInTheDocument();
});
});

View File

@ -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<OpenTsDatasource, OpenTsdbQuery, OpenTsdbOptions>;
export function OpenTsdbQueryEditor({
datasource,
onRunQuery,
onChange,
query,
range,
queries,
}: OpenTsdbQueryEditorProps) {
const styles = useStyles2(getStyles);
const [aggregators, setAggregators] = useState<string[]>([
'avg',
'sum',
'min',
'max',
'dev',
'zimsum',
'mimmin',
'mimmax',
]);
const fillPolicies: string[] = ['none', 'nan', 'null', 'zero'];
const [filterTypes, setFilterTypes] = useState<string[]>([
'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<Array<{ value: string; description: string }>> {
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<Array<{ value: string; description: string }>> {
return datasource.metricFindQuery('suggest_tagv()').then(getTextValues);
}
async function suggestTagKeys(query: OpenTsdbQuery): Promise<string[]> {
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 (
<div className={styles.container} data-testid={testIds.editor}>
<div className={styles.visualEditor}>
<MetricSection
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
suggestMetrics={suggestMetrics}
aggregators={aggregators}
/>
<DownSample
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
aggregators={aggregators}
fillPolicies={fillPolicies}
tsdbVersion={tsdbVersion}
/>
{tsdbVersion >= 2 && (
<FilterSection
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
filterTypes={filterTypes}
suggestTagValues={suggestTagValues}
suggestTagKeys={suggestTagKeys}
/>
)}
<TagSection
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
suggestTagValues={suggestTagValues}
suggestTagKeys={suggestTagKeys}
tsdbVersion={tsdbVersion}
/>
<RateSection query={query} onChange={onChange} onRunQuery={onRunQuery} tsdbVersion={tsdbVersion} />
</div>
</div>
);
}
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',
};

View File

@ -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(<RateSection {...props} />);
};
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();
});
});
});

View File

@ -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 (
<div className="gf-form-inline" data-testid={testIds.section}>
<div className="gf-form">
<InlineFormLabel className="query-keyword" width={8}>
Rate
</InlineFormLabel>
<InlineSwitch
data-testid={testIds.shouldComputeRate}
value={query.shouldComputeRate ?? false}
onChange={() => {
const shouldComputeRate = query.shouldComputeRate ?? false;
onChange({ ...query, shouldComputeRate: !shouldComputeRate });
onRunQuery();
}}
/>
</div>
{query.shouldComputeRate && (
<div className="gf-form">
<InlineFormLabel className="query-keyword" width={'auto'}>
Counter
</InlineFormLabel>
<InlineSwitch
data-testid={testIds.isCounter}
value={query.isCounter ?? false}
onChange={() => {
const isCounter = query.isCounter ?? false;
onChange({ ...query, isCounter: !isCounter });
onRunQuery();
}}
/>
</div>
)}
{query.shouldComputeRate && query.isCounter && (
<div className="gf-form">
<InlineLabel width={'auto'} className="query-keyword">
Counter max
</InlineLabel>
<Input
data-testid={testIds.counterMax}
placeholder="max value"
value={query.counterMax ?? ''}
onChange={(e) => {
const value = e.currentTarget.value;
onChange({ ...query, counterMax: value });
}}
onBlur={() => onRunQuery()}
/>
<InlineLabel width={'auto'} className="query-keyword">
Reset value
</InlineLabel>
<Input
data-testid={testIds.counterResetValue}
placeholder="reset value"
value={query.counterResetValue ?? ''}
onChange={(e) => {
const value = e.currentTarget.value;
onChange({ ...query, counterResetValue: value });
}}
onBlur={() => onRunQuery()}
/>
</div>
)}
{tsdbVersion > 2 && (
<div className="gf-form">
<InlineFormLabel className="query-keyword" width={'auto'}>
Explicit tags
</InlineFormLabel>
<InlineSwitch
data-testid={testIds.explicitTags}
value={query.explicitTags ?? false}
onChange={() => {
const explicitTags = query.explicitTags ?? false;
onChange({ ...query, explicitTags: !explicitTags });
onRunQuery();
}}
/>
</div>
)}
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow"></div>
</div>
</div>
);
}
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',
};

View File

@ -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(<TagSection {...props} />);
};
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();
});
});
});

View File

@ -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<string[]>;
suggestTagValues: () => Promise<SelectableValue[]>;
tsdbVersion: number;
}
export function TagSection({
query,
onChange,
onRunQuery,
suggestTagKeys,
suggestTagValues,
tsdbVersion,
}: TagSectionProps) {
const [tagKeys, updTagKeys] = useState<Array<SelectableValue<string>>>();
const [keyIsLoading, updKeyIsLoading] = useState<boolean>();
const [tagValues, updTagValues] = useState<Array<SelectableValue<string>>>();
const [valueIsLoading, updValueIsLoading] = useState<boolean>();
const [addTagMode, updAddTagMode] = useState<boolean>(false);
const [curTagKey, updCurTagKey] = useState<string | number>('');
const [curTagValue, updCurTagValue] = useState<string>('');
const [errors, setErrors] = useState<string>('');
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<string>, searchQuery: string) => {
const label = option.value ?? '';
const searchWords = searchQuery.split(splitSeparator);
return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true);
}, []);
return (
<div className="gf-form-inline" data-testid={testIds.section}>
<div className="gf-form">
<InlineFormLabel
className="query-keyword"
width={8}
tooltip={tsdbVersion >= 2 ? <div>Please use filters, tags are deprecated in opentsdb 2.2</div> : undefined}
>
Tags
</InlineFormLabel>
{query.tags &&
Object.keys(query.tags).map((tagKey: string | number, idx: number) => {
const tagValue = query.tags[tagKey];
return (
<InlineFormLabel key={idx} width="auto" data-testid={testIds.list + idx}>
{tagKey}={tagValue}
<a onClick={() => editTag(tagKey, tagValue)}>
<Icon name={'pen'} />
</a>
<a onClick={() => removeTag(tagKey)} data-testid={testIds.remove}>
<Icon name={'times'} />
</a>
</InlineFormLabel>
);
})}
{!addTagMode && (
<label className="gf-form-label query-keyword">
<a onClick={changeAddTagMode} data-testid={testIds.open}>
<Icon name={'plus'} />
</a>
</label>
)}
</div>
{addTagMode && (
<div className="gf-form-inline">
<div className="gf-form">
<Select
inputId="opentsdb-suggested-tagk-select"
className="gf-form-input"
value={curTagKey ? toOption('' + curTagKey) : undefined}
placeholder="key"
onOpenMenu={async () => {
updKeyIsLoading(true);
const tKs = await suggestTagKeys(query);
const tKsOptions = tKs.map((value: string) => toOption(value));
updTagKeys(tKsOptions);
updKeyIsLoading(false);
}}
isLoading={keyIsLoading}
options={tagKeys}
onChange={({ value }) => {
if (value) {
updCurTagKey(value);
}
}}
/>
</div>
<div className="gf-form">
<Select
inputId="opentsdb-suggested-tagv-select"
className="gf-form-input"
value={curTagValue ? toOption(curTagValue) : undefined}
placeholder="value"
allowCustomValue
filterOption={customTagOption}
onOpenMenu={async () => {
if (!tagValues) {
updValueIsLoading(true);
const tVs = await suggestTagValues();
updTagValues(tVs);
updValueIsLoading(false);
}
}}
isLoading={valueIsLoading}
options={tagValues}
onChange={({ value }) => {
if (value) {
updCurTagValue(value);
}
}}
/>
</div>
<div className="gf-form">
{errors && (
<label className="gf-form-label" title={errors} data-testid={testIds.error}>
<Icon name={'exclamation-triangle'} color={'rgb(229, 189, 28)'} />
</label>
)}
<label className="gf-form-label">
<a onClick={addTag}>add tag</a>
<a onClick={changeAddTagMode}>
<Icon name={'times'} />
</a>
</label>
</div>
</div>
)}
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow"></div>
</div>
</div>
);
}
export const testIds = {
section: 'opentsdb-tag',
open: 'opentsdb-tag-editor',
list: 'opentsdb-tag-list',
error: 'opentsdb-tag-error',
remove: 'opentsdb-tag-remove',
};

View File

@ -0,0 +1,5 @@
import { css } from '@emotion/css';
export const paddingRightClass = css({
paddingRight: '4px',
});

View File

@ -1,6 +1,6 @@
import angular from 'angular';
import {
clone,
cloneDeep,
compact,
each,
every,
@ -242,7 +242,8 @@ export default class OpenTsDatasource extends DataSourceApi<OpenTsdbQuery, OpenT
return getBackendSrv().fetch(options);
}
suggestTagKeys(metric: string | number) {
suggestTagKeys(query: OpenTsdbQuery) {
const metric = query.metric ?? '';
return Promise.resolve(this.tagKeys[metric] || []);
}
@ -537,7 +538,8 @@ export default class OpenTsDatasource extends DataSourceApi<OpenTsdbQuery, OpenT
}
if (target.filters && target.filters.length > 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<OpenTsdbQuery, OpenT
}
}
} else {
query.tags = angular.copy(target.tags);
query.tags = cloneDeep(target.tags);
if (query.tags) {
for (const tagKey in query.tags) {
query.tags[tagKey] = this.templateSrv.replace(query.tags[tagKey], options.scopedVars, 'pipe');

View File

@ -1,9 +1,9 @@
import { DataSourcePlugin } from '@grafana/data';
import { ConfigEditor } from './components/ConfigEditor';
import { OpenTsdbQueryEditor } from './components/OpenTsdbQueryEditor';
import OpenTsDatasource from './datasource';
import { OpenTsQueryCtrl } from './query_ctrl';
export const plugin = new DataSourcePlugin(OpenTsDatasource)
.setQueryCtrl(OpenTsQueryCtrl)
.setQueryEditor(OpenTsdbQueryEditor)
.setConfigEditor(ConfigEditor);

View File

@ -1,264 +0,0 @@
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<div class="gf-form-inline">
<div class="gf-form max-width-25">
<label class="gf-form-label query-keyword width-8">
Metric
<label class="gf-form-label" bs-tooltip="ctrl.errors.metric" style="color: rgb(229, 189, 28)" ng-show="ctrl.errors.metric">
<icon name="'exclamation-triangle'"></icon>
</label>
</label>
<input type="text" class="gf-form-input" ng-model="ctrl.target.metric"
spellcheck='false' bs-typeahead="ctrl.suggestMetrics" placeholder="metric name" data-min-length=0 data-items=100
ng-blur="ctrl.targetBlur()">
</input>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword">
Aggregator
<a bs-tooltip="ctrl.errors.aggregator" style="color: rgb(229, 189, 28)" ng-show="ctrl.errors.aggregator">
<icon name="'exclamation-triangle'"></icon>
</a>
</label>
<div class="gf-form-select-wrapper max-width-15">
<select aria-label="Metric aggregator"
ng-model="ctrl.target.aggregator" class="gf-form-input"
ng-options="agg for agg in ctrl.aggregators"
ng-change="ctrl.targetBlur()">
</select>
</div>
</div>
<div class="gf-form max-width-20">
<label class="gf-form-label query-keyword width-6">
Alias:
<info-popover mode="right-normal">
Use patterns like $tag_tagname to replace part of the alias for a tag value
</info-popover>
</label>
<input type="text" class="gf-form-input"
ng-model="ctrl.target.alias"
spellcheck='false'
placeholder="series alias"
data-min-length=0 data-items=100
ng-blur="ctrl.targetBlur()"></input>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form max-width-25">
<label class="gf-form-label query-keyword width-8">Down sample</label>
<input type="text" class="gf-form-input gf-form-input--has-help-icon"
ng-model="ctrl.target.downsampleInterval"
ng-model-onblur
ng-change="ctrl.targetBlur()"
placeholder="interval"></input>
<info-popover mode="right-absolute">
blank for auto, or for example <code>1m</code>
</info-popover>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword">Aggregator</label>
<div class="gf-form-select-wrapper">
<select aria-label="Downsample aggregator"
ng-model="ctrl.target.downsampleAggregator" class="gf-form-input"
ng-options="agg for agg in ctrl.aggregators"
ng-change="ctrl.targetBlur()">
</select>
</div>
</div>
<div class="gf-form" ng-if="ctrl.tsdbVersion >= 2">
<label class="gf-form-label query-keyword width-6">Fill</label>
<div class="gf-form-select-wrapper">
<select ng-model="ctrl.target.downsampleFillPolicy" class="gf-form-input"
ng-options="agg for agg in ctrl.fillPolicies"
ng-change="ctrl.targetBlur()">
</select>
</div>
</div>
<gf-form-switch class="gf-form"
label="Disable downsampling"
label-class="query-keyword"
checked="ctrl.target.disableDownsampling"
on-change="ctrl.targetBlur()">
</gf-form-switch>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.tsdbVersion >= 2">
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">
Filters
<info-popover mode="right-normal">
Filters does not work with tags, either of the two will work but not both.
</info-popover>
</label>
<div ng-repeat="fil in ctrl.target.filters track by $index" class="gf-form-label">
{{fil.tagk}}&nbsp;=&nbsp;{{fil.type}}&#40;{{fil.filter}}&#41;&nbsp;&#44&nbsp;groupBy&nbsp;=&nbsp;{{fil.groupBy}}
<a ng-click="ctrl.editFilter(fil, $index)">
<icon name="'pen'"></icon>
</a>
<a ng-click="ctrl.removeFilter($index)">
<icon name="'times'"></icon>
</a>
</div>
<label class="gf-form-label query-keyword" ng-hide="ctrl.addFilterMode">
<a ng-click="ctrl.addFilter()">
<icon name="'plus'"></icon>
</a>
</label>
</div>
<div class="gf-form-inline" ng-show="ctrl.addFilterMode">
<div class="gf-form">
<input type="text" class="gf-form-input" spellcheck='false'
bs-typeahead="ctrl.suggestTagKeys" data-min-length=0 data-items=100
ng-model="ctrl.target.currentFilterKey" placeholder="key">
</input>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword">Type</label>
<div class="gf-form-select-wrapper">
<select ng-model="ctrl.target.currentFilterType" class="gf-form-input" ng-options="filType for filType in ctrl.filterTypes">
</select>
</div>
</div>
<div class="gf-form">
<input type="text" class="gf-form-input" spellcheck='false' bs-typeahead="ctrl.suggestTagValues" data-min-length=0 data-items=100 ng-model="ctrl.target.currentFilterValue" placeholder="filter">
</input>
</div>
<gf-form-switch class="gf-form"
label="Group by"
label-class="query-keyword"
checked="ctrl.target.currentFilterGroupBy"
on-change="ctrl.targetBlur()">
</gf-form-switch>
<div class="gf-form" ng-show="ctrl.addFilterMode">
<label class="gf-form-label" ng-show="ctrl.errors.filters">
<a bs-tooltip="ctrl.errors.filters" style="color: rgb(229, 189, 28)" >
<icon name="'exclamation-triangle'"></icon>
</a>
</label>
<label class="gf-form-label">
<a ng-click="ctrl.addFilter()" ng-hide="ctrl.errors.filters">add filter</a>
<a ng-click="ctrl.closeAddFilterMode()">
<icon name="'times'"></icon>
</a>
</label>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">
Tags
<info-popover mode="right-normal" ng-if="ctrl.tsdbVersion >= 2">
Please use filters, tags are deprecated in opentsdb 2.2
</info-popover>
</label>
</div>
<div class="gf-form" ng-repeat="(key, value) in ctrl.target.tags track by $index" class="gf-form">
<label class="gf-form-label">
{{key}}&nbsp;=&nbsp;{{value}}
<a ng-click="ctrl.editTag(key, value)">
<icon name="'pen'"></icon>
</a>
<a ng-click="ctrl.removeTag(key)">
<icon name="'times'"></icon>
</a>
</label>
</div>
<div class="gf-form" ng-hide="ctrl.addTagMode">
<label class="gf-form-label query-keyword">
<a ng-click="ctrl.addTag()"><icon name="'plus'"></icon></a>
</label>
</div>
<div class="gf-form" ng-show="ctrl.addTagMode">
<input type="text"
class="gf-form-input" spellcheck='false'
bs-typeahead="ctrl.suggestTagKeys" data-min-length=0 data-items=100
ng-model="ctrl.target.currentTagKey" placeholder="key">
</input>
<input type="text" class="gf-form-input"
spellcheck='false' bs-typeahead="ctrl.suggestTagValues"
data-min-length=0 data-items=100 ng-model="ctrl.target.currentTagValue" placeholder="value">
</input>
<label class="gf-form-label" ng-show="ctrl.errors.tags">
<a bs-tooltip="ctrl.errors.tags" style="color: rgb(229, 189, 28)" >
<icon name="'exclamation-triangle'"></icon>
</a>
</label>
<label class="gf-form-label" >
<a ng-click="ctrl.addTag()" ng-hide="ctrl.errors.tags">add tag</a>
<a ng-click="ctrl.closeAddTagMode()"><icon name="'times'" size="'sm'"></icon></a>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Rate" label-class="width-8 query-keyword" checked="ctrl.target.shouldComputeRate" on-change="ctrl.targetBlur()">
</gf-form-switch>
<gf-form-switch ng-hide="!ctrl.target.shouldComputeRate"
class="gf-form" label="Counter" label-class="query-keyword" checked="ctrl.target.isCounter" on-change="ctrl.targetBlur()">
</gf-form-switch>
<div class="gf-form" ng-hide="!ctrl.target.isCounter || !ctrl.target.shouldComputeRate">
<label class="gf-form-label query-keyword">Counter Max</label>
<input type="text" class="gf-form-input"
ng-disabled="!ctrl.target.shouldComputeRate"
ng-model="ctrl.target.counterMax" spellcheck='false'
placeholder="max value" ng-model-onblur
ng-blur="ctrl.targetBlur()">
</input>
<label class="gf-form-label query-keyword">Reset Value</label>
<input type="text" class="tight-form-input input-small"
ng-disabled="!ctrl.target.shouldComputeRate"
ng-model="ctrl.target.counterResetValue" spellcheck='false'
placeholder="reset value" ng-model-onblur
ng-blur="ctrl.targetBlur()">
</input>
</div>
<div class="gf-form" ng-if="ctrl.tsdbVersion > 2">
<gf-form-switch class="gf-form" label="Explicit tags" label-class="width-8 query-keyword" checked="ctrl.target.explicitTags" on-change="ctrl.targetBlur()">
</gf-form-switch>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</query-editor-row>

View File

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

View File

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

View File

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