mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
ad48cee2bb
commit
82d8015469
@ -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"],
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
103
public/app/plugins/datasource/opentsdb/components/DownSample.tsx
Normal file
103
public/app/plugins/datasource/opentsdb/components/DownSample.tsx
Normal 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',
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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',
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
219
public/app/plugins/datasource/opentsdb/components/TagSection.tsx
Normal file
219
public/app/plugins/datasource/opentsdb/components/TagSection.tsx
Normal 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',
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const paddingRightClass = css({
|
||||
paddingRight: '4px',
|
||||
});
|
@ -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');
|
||||
|
@ -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);
|
||||
|
@ -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}} = {{fil.type}}({{fil.filter}}) , groupBy = {{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}} = {{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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user