mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Tempo: TraceQL Configurable static fields (#65284)
* TraceQL - configurable static fields for new UI * TraceQL - filter out static fields from Tags section. Added tooltip to static fields * Add more units to duration validation. Improve duration field tooltip with accepted units * Better control of delete button on SearchField * Move new config behind feature toggle * Special title for intrinsic "name" * Fix tests * Move static fields not in the datasource to the Tags section * Start using the useAsync hook in the Tempo TraceQL configuration page to retrieve the tags and datasource * Fix tests * Fix test. Use useAsync to retrieve options in SearchField * Remove ability to set a default value in filter configuration. Removed type from filter, dynamic filters are now any filters not present in the datasource config * Updated the static filters tooltip * Replace useState + useEffect with useMemo for scopedTag
This commit is contained in:
parent
fb83414b6a
commit
541a03f33b
@ -4994,6 +4994,9 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||||
],
|
],
|
||||||
|
"public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx:5381": [
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
|
],
|
||||||
"public/app/plugins/datasource/tempo/datasource.test.ts:5381": [
|
"public/app/plugins/datasource/tempo/datasource.test.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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.", "1"],
|
||||||
|
@ -16,12 +16,6 @@ const (
|
|||||||
TempoQueryFiltersScopeUnscoped TempoQueryFiltersScope = "unscoped"
|
TempoQueryFiltersScopeUnscoped TempoQueryFiltersScope = "unscoped"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines values for TempoQueryFiltersType.
|
|
||||||
const (
|
|
||||||
TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic"
|
|
||||||
TempoQueryFiltersTypeStatic TempoQueryFiltersType = "static"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Defines values for TempoQueryType.
|
// Defines values for TempoQueryType.
|
||||||
const (
|
const (
|
||||||
TempoQueryTypeClear TempoQueryType = "clear"
|
TempoQueryTypeClear TempoQueryType = "clear"
|
||||||
@ -40,18 +34,6 @@ const (
|
|||||||
TraceqlFilterScopeUnscoped TraceqlFilterScope = "unscoped"
|
TraceqlFilterScopeUnscoped TraceqlFilterScope = "unscoped"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines values for TraceqlFilterType.
|
|
||||||
const (
|
|
||||||
TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic"
|
|
||||||
TraceqlFilterTypeStatic TraceqlFilterType = "static"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Defines values for TraceqlSearchFilterType.
|
|
||||||
const (
|
|
||||||
TraceqlSearchFilterTypeDynamic TraceqlSearchFilterType = "dynamic"
|
|
||||||
TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Defines values for TraceqlSearchScope.
|
// Defines values for TraceqlSearchScope.
|
||||||
const (
|
const (
|
||||||
TraceqlSearchScopeResource TraceqlSearchScope = "resource"
|
TraceqlSearchScopeResource TraceqlSearchScope = "resource"
|
||||||
@ -82,9 +64,6 @@ type TempoQuery struct {
|
|||||||
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
Tag *string `json:"tag,omitempty"`
|
Tag *string `json:"tag,omitempty"`
|
||||||
|
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
|
||||||
Type TempoQueryFiltersType `json:"type"`
|
|
||||||
|
|
||||||
// The value for the search filter
|
// The value for the search filter
|
||||||
Value *interface{} `json:"value,omitempty"`
|
Value *interface{} `json:"value,omitempty"`
|
||||||
|
|
||||||
@ -134,9 +113,6 @@ type TempoQuery struct {
|
|||||||
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
type TempoQueryFiltersScope string
|
type TempoQueryFiltersScope string
|
||||||
|
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
|
||||||
type TempoQueryFiltersType string
|
|
||||||
|
|
||||||
// TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
// TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
||||||
type TempoQueryType string
|
type TempoQueryType string
|
||||||
|
|
||||||
@ -154,9 +130,6 @@ type TraceqlFilter struct {
|
|||||||
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
Tag *string `json:"tag,omitempty"`
|
Tag *string `json:"tag,omitempty"`
|
||||||
|
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
|
||||||
Type TraceqlFilterType `json:"type"`
|
|
||||||
|
|
||||||
// The value for the search filter
|
// The value for the search filter
|
||||||
Value *interface{} `json:"value,omitempty"`
|
Value *interface{} `json:"value,omitempty"`
|
||||||
|
|
||||||
@ -167,11 +140,5 @@ type TraceqlFilter struct {
|
|||||||
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
type TraceqlFilterScope string
|
type TraceqlFilterScope string
|
||||||
|
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
// TraceqlSearchScope static fields are pre-set in the UI, dynamic fields are added by the user
|
||||||
type TraceqlFilterType string
|
|
||||||
|
|
||||||
// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user
|
|
||||||
type TraceqlSearchFilterType string
|
|
||||||
|
|
||||||
// TraceqlSearchScope defines model for TraceqlSearchScope.
|
|
||||||
type TraceqlSearchScope string
|
type TraceqlSearchScope string
|
||||||
|
@ -13,7 +13,7 @@ interface Props {
|
|||||||
operators: string[];
|
operators: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationRegex = /^\d+(?:\.\d)?\d*(?:ms|s|ns)$/;
|
const validationRegex = /^\d+(?:\.\d)?\d*(?:us|µs|ns|ms|s|m|h)$/;
|
||||||
|
|
||||||
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
||||||
let invalid = false;
|
let invalid = false;
|
||||||
|
@ -10,7 +10,7 @@ interface Props {
|
|||||||
const SearchField = ({ label, tooltip, children }: Props) => {
|
const SearchField = ({ label, tooltip, children }: Props) => {
|
||||||
return (
|
return (
|
||||||
<InlineFieldRow>
|
<InlineFieldRow>
|
||||||
<InlineField label={label} labelWidth={16} grow tooltip={tooltip}>
|
<InlineField label={label} labelWidth={28} grow tooltip={tooltip}>
|
||||||
{children}
|
{children}
|
||||||
</InlineField>
|
</InlineField>
|
||||||
</InlineFieldRow>
|
</InlineFieldRow>
|
||||||
|
@ -4,7 +4,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { FetchError } from '@grafana/runtime';
|
import { FetchError } from '@grafana/runtime';
|
||||||
|
|
||||||
import { TraceqlFilter } from '../dataquery.gen';
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
|
|
||||||
import SearchField from './SearchField';
|
import SearchField from './SearchField';
|
||||||
@ -48,12 +48,13 @@ describe('SearchField', () => {
|
|||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render tag if tag is present in field', () => {
|
it('should not render tag if hideTag is true', () => {
|
||||||
const updateFilter = jest.fn((val) => {
|
const updateFilter = jest.fn((val) => {
|
||||||
return val;
|
return val;
|
||||||
});
|
});
|
||||||
const filter: TraceqlFilter = { id: 'test1', type: 'static', valueType: 'string', tag: 'test-tag' };
|
const filter: TraceqlFilter = { id: 'test1', valueType: 'string', tag: 'test-tag' };
|
||||||
const { container } = renderSearchField(updateFilter, filter);
|
|
||||||
|
const { container } = renderSearchField(updateFilter, filter, [], true);
|
||||||
|
|
||||||
expect(container.querySelector(`input[aria-label="select test1 tag"]`)).not.toBeInTheDocument();
|
expect(container.querySelector(`input[aria-label="select test1 tag"]`)).not.toBeInTheDocument();
|
||||||
expect(container.querySelector(`input[aria-label="select test1 operator"]`)).toBeInTheDocument();
|
expect(container.querySelector(`input[aria-label="select test1 operator"]`)).toBeInTheDocument();
|
||||||
@ -64,7 +65,7 @@ describe('SearchField', () => {
|
|||||||
const updateFilter = jest.fn((val) => {
|
const updateFilter = jest.fn((val) => {
|
||||||
return val;
|
return val;
|
||||||
});
|
});
|
||||||
const filter: TraceqlFilter = { id: 'test1', operator: '=', type: 'static', valueType: 'string', tag: 'test-tag' };
|
const filter: TraceqlFilter = { id: 'test1', operator: '=', valueType: 'string', tag: 'test-tag' };
|
||||||
const { container } = renderSearchField(updateFilter, filter);
|
const { container } = renderSearchField(updateFilter, filter);
|
||||||
|
|
||||||
const select = await container.querySelector(`input[aria-label="select test1 operator"]`);
|
const select = await container.querySelector(`input[aria-label="select test1 operator"]`);
|
||||||
@ -87,7 +88,6 @@ describe('SearchField', () => {
|
|||||||
const filter: TraceqlFilter = {
|
const filter: TraceqlFilter = {
|
||||||
id: 'test1',
|
id: 'test1',
|
||||||
value: 'old',
|
value: 'old',
|
||||||
type: 'static',
|
|
||||||
valueType: 'string',
|
valueType: 'string',
|
||||||
tag: 'test-tag',
|
tag: 'test-tag',
|
||||||
};
|
};
|
||||||
@ -124,7 +124,6 @@ describe('SearchField', () => {
|
|||||||
});
|
});
|
||||||
const filter: TraceqlFilter = {
|
const filter: TraceqlFilter = {
|
||||||
id: 'test1',
|
id: 'test1',
|
||||||
type: 'dynamic',
|
|
||||||
valueType: 'string',
|
valueType: 'string',
|
||||||
};
|
};
|
||||||
const { container } = renderSearchField(updateFilter, filter, ['tag1', 'tag22', 'tag33']);
|
const { container } = renderSearchField(updateFilter, filter, ['tag1', 'tag22', 'tag33']);
|
||||||
@ -155,16 +154,35 @@ describe('SearchField', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderSearchField = (updateFilter: (f: TraceqlFilter) => void, filter: TraceqlFilter, tags?: string[]) => {
|
const renderSearchField = (
|
||||||
|
updateFilter: (f: TraceqlFilter) => void,
|
||||||
|
filter: TraceqlFilter,
|
||||||
|
tags?: string[],
|
||||||
|
hideTag?: boolean
|
||||||
|
) => {
|
||||||
|
const datasource: TempoDatasource = {
|
||||||
|
search: {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: 'service-name',
|
||||||
|
tag: 'service.name',
|
||||||
|
operator: '=',
|
||||||
|
scope: TraceqlSearchScope.Resource,
|
||||||
|
},
|
||||||
|
{ id: 'span-name', type: 'static', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as TempoDatasource;
|
||||||
return render(
|
return render(
|
||||||
<SearchField
|
<SearchField
|
||||||
datasource={{} as TempoDatasource}
|
datasource={datasource}
|
||||||
updateFilter={updateFilter}
|
updateFilter={updateFilter}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
setError={function (error: FetchError): void {
|
setError={function (error: FetchError): void {
|
||||||
throw error;
|
throw error;
|
||||||
}}
|
}}
|
||||||
tags={tags || []}
|
tags={tags || []}
|
||||||
|
hideTag={hideTag}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import { uniq } from 'lodash';
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import useAsync from 'react-use/lib/useAsync';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
|
||||||
import { AccessoryButton } from '@grafana/experimental';
|
import { AccessoryButton } from '@grafana/experimental';
|
||||||
import { FetchError, isFetchError } from '@grafana/runtime';
|
import { FetchError, isFetchError } from '@grafana/runtime';
|
||||||
import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||||
@ -14,7 +15,7 @@ import { TempoDatasource } from '../datasource';
|
|||||||
import TempoLanguageProvider from '../language_provider';
|
import TempoLanguageProvider from '../language_provider';
|
||||||
import { operators as allOperators, stringOperators, numberOperators } from '../traceql/traceql';
|
import { operators as allOperators, stringOperators, numberOperators } from '../traceql/traceql';
|
||||||
|
|
||||||
import { operatorSelectableValue, scopeHelper } from './utils';
|
import { filterScopedTag, operatorSelectableValue } from './utils';
|
||||||
|
|
||||||
const getStyles = () => ({
|
const getStyles = () => ({
|
||||||
dropdown: css`
|
dropdown: css`
|
||||||
@ -30,19 +31,49 @@ interface Props {
|
|||||||
setError: (error: FetchError) => void;
|
setError: (error: FetchError) => void;
|
||||||
isTagsLoading?: boolean;
|
isTagsLoading?: boolean;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
hideScope?: boolean;
|
||||||
|
hideTag?: boolean;
|
||||||
|
hideValue?: boolean;
|
||||||
|
allowDelete?: boolean;
|
||||||
}
|
}
|
||||||
const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoading, tags, setError }: Props) => {
|
const SearchField = ({
|
||||||
|
filter,
|
||||||
|
datasource,
|
||||||
|
updateFilter,
|
||||||
|
deleteFilter,
|
||||||
|
isTagsLoading,
|
||||||
|
tags,
|
||||||
|
setError,
|
||||||
|
hideScope,
|
||||||
|
hideTag,
|
||||||
|
hideValue,
|
||||||
|
allowDelete,
|
||||||
|
}: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
|
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
|
||||||
const [isLoadingValues, setIsLoadingValues] = useState(false);
|
const scopedTag = useMemo(() => filterScopedTag(filter), [filter]);
|
||||||
const [options, setOptions] = useState<Array<SelectableValue<string>>>([]);
|
|
||||||
const [scopedTag, setScopedTag] = useState(scopeHelper(filter) + filter.tag);
|
|
||||||
// We automatically change the operator to the regex op when users select 2 or more values
|
// We automatically change the operator to the regex op when users select 2 or more values
|
||||||
// However, they expect this to be automatically rolled back to the previous operator once
|
// However, they expect this to be automatically rolled back to the previous operator once
|
||||||
// there's only one value selected, so we store the previous operator and value
|
// there's only one value selected, so we store the previous operator and value
|
||||||
const [prevOperator, setPrevOperator] = useState(filter.operator);
|
const [prevOperator, setPrevOperator] = useState(filter.operator);
|
||||||
const [prevValue, setPrevValue] = useState(filter.value);
|
const [prevValue, setPrevValue] = useState(filter.value);
|
||||||
|
|
||||||
|
const updateOptions = async () => {
|
||||||
|
try {
|
||||||
|
return await languageProvider.getOptionsV2(scopedTag);
|
||||||
|
} catch (error) {
|
||||||
|
// Display message if Tempo is connected but search 404's
|
||||||
|
if (isFetchError(error) && error?.status === 404) {
|
||||||
|
setError(error);
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
dispatch(notifyApp(createErrorNotification('Error', error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loading: isLoadingValues, value: options } = useAsync(updateOptions, [scopedTag, languageProvider, setError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Array.isArray(filter.value) && filter.value.length > 1 && filter.operator !== '=~') {
|
if (Array.isArray(filter.value) && filter.value.length > 1 && filter.operator !== '=~') {
|
||||||
setPrevOperator(filter.operator);
|
setPrevOperator(filter.operator);
|
||||||
@ -57,38 +88,11 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa
|
|||||||
setPrevValue(filter.value);
|
setPrevValue(filter.value);
|
||||||
}, [filter.value]);
|
}, [filter.value]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newScopedTag = scopeHelper(filter) + filter.tag;
|
|
||||||
if (newScopedTag !== scopedTag) {
|
|
||||||
setScopedTag(newScopedTag);
|
|
||||||
}
|
|
||||||
}, [filter, scopedTag]);
|
|
||||||
|
|
||||||
const updateOptions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setIsLoadingValues(true);
|
|
||||||
setOptions(await languageProvider.getOptionsV2(scopedTag));
|
|
||||||
} catch (error) {
|
|
||||||
// Display message if Tempo is connected but search 404's
|
|
||||||
if (isFetchError(error) && error?.status === 404) {
|
|
||||||
setError(error);
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
dispatch(notifyApp(createErrorNotification('Error', error)));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoadingValues(false);
|
|
||||||
}
|
|
||||||
}, [scopedTag, languageProvider, setError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateOptions();
|
|
||||||
}, [updateOptions]);
|
|
||||||
|
|
||||||
const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t }));
|
const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t }));
|
||||||
|
|
||||||
// If all values have type string or int/float use a focused list of operators instead of all operators
|
// If all values have type string or int/float use a focused list of operators instead of all operators
|
||||||
const optionsOfFirstType = options.filter((o) => o.type === options[0]?.type);
|
const optionsOfFirstType = options?.filter((o) => o.type === options[0]?.type);
|
||||||
const uniqueOptionType = options.length === optionsOfFirstType.length ? options[0]?.type : undefined;
|
const uniqueOptionType = options?.length === optionsOfFirstType?.length ? options?.[0]?.type : undefined;
|
||||||
let operatorList = allOperators;
|
let operatorList = allOperators;
|
||||||
switch (uniqueOptionType) {
|
switch (uniqueOptionType) {
|
||||||
case 'string':
|
case 'string':
|
||||||
@ -101,7 +105,7 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup spacing={'none'} width={'auto'}>
|
<HorizontalGroup spacing={'none'} width={'auto'}>
|
||||||
{filter.type === 'dynamic' && (
|
{!hideScope && (
|
||||||
<Select
|
<Select
|
||||||
className={styles.dropdown}
|
className={styles.dropdown}
|
||||||
inputId={`${filter.id}-scope`}
|
inputId={`${filter.id}-scope`}
|
||||||
@ -114,12 +118,16 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa
|
|||||||
aria-label={`select ${filter.id} scope`}
|
aria-label={`select ${filter.id} scope`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filter.type === 'dynamic' && (
|
{!hideTag && (
|
||||||
<Select
|
<Select
|
||||||
className={styles.dropdown}
|
className={styles.dropdown}
|
||||||
inputId={`${filter.id}-tag`}
|
inputId={`${filter.id}-tag`}
|
||||||
isLoading={isTagsLoading}
|
isLoading={isTagsLoading}
|
||||||
options={tags.map((t) => ({ label: t, value: t }))}
|
// Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value
|
||||||
|
options={(filter.tag !== undefined ? uniq([filter.tag, ...tags]) : tags).map((t) => ({
|
||||||
|
label: t,
|
||||||
|
value: t,
|
||||||
|
}))}
|
||||||
value={filter.tag}
|
value={filter.tag}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
updateFilter({ ...filter, tag: v?.value });
|
updateFilter({ ...filter, tag: v?.value });
|
||||||
@ -143,26 +151,28 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa
|
|||||||
allowCustomValue={true}
|
allowCustomValue={true}
|
||||||
width={8}
|
width={8}
|
||||||
/>
|
/>
|
||||||
<Select
|
{!hideValue && (
|
||||||
className={styles.dropdown}
|
<Select
|
||||||
inputId={`${filter.id}-value`}
|
className={styles.dropdown}
|
||||||
isLoading={isLoadingValues}
|
inputId={`${filter.id}-value`}
|
||||||
options={options}
|
isLoading={isLoadingValues}
|
||||||
value={filter.value}
|
options={options}
|
||||||
onChange={(val) => {
|
value={filter.value}
|
||||||
if (Array.isArray(val)) {
|
onChange={(val) => {
|
||||||
updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type });
|
if (Array.isArray(val)) {
|
||||||
} else {
|
updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type });
|
||||||
updateFilter({ ...filter, value: val?.value, valueType: val?.type });
|
} else {
|
||||||
}
|
updateFilter({ ...filter, value: val?.value, valueType: val?.type });
|
||||||
}}
|
}
|
||||||
placeholder="Select value"
|
}}
|
||||||
isClearable={false}
|
placeholder="Select value"
|
||||||
aria-label={`select ${filter.id} value`}
|
isClearable={false}
|
||||||
allowCustomValue={true}
|
aria-label={`select ${filter.id} value`}
|
||||||
isMulti
|
allowCustomValue={true}
|
||||||
/>
|
isMulti
|
||||||
{filter.type === 'dynamic' && (
|
/>
|
||||||
|
)}
|
||||||
|
{allowDelete && (
|
||||||
<AccessoryButton
|
<AccessoryButton
|
||||||
variant={'secondary'}
|
variant={'secondary'}
|
||||||
icon={'times'}
|
icon={'times'}
|
||||||
|
@ -32,26 +32,34 @@ interface Props {
|
|||||||
setError: (error: FetchError) => void;
|
setError: (error: FetchError) => void;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isTagsLoading: boolean;
|
isTagsLoading: boolean;
|
||||||
|
hideValues?: boolean;
|
||||||
}
|
}
|
||||||
const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => {
|
const TagsInput = ({
|
||||||
|
updateFilter,
|
||||||
|
deleteFilter,
|
||||||
|
filters,
|
||||||
|
datasource,
|
||||||
|
setError,
|
||||||
|
tags,
|
||||||
|
isTagsLoading,
|
||||||
|
hideValues,
|
||||||
|
}: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const generateId = () => uuidv4().slice(0, 8);
|
const generateId = () => uuidv4().slice(0, 8);
|
||||||
const handleOnAdd = useCallback(
|
const handleOnAdd = useCallback(
|
||||||
() => updateFilter({ id: generateId(), type: 'dynamic', operator: '=', scope: TraceqlSearchScope.Span }),
|
() => updateFilter({ id: generateId(), operator: '=', scope: TraceqlSearchScope.Span }),
|
||||||
[updateFilter]
|
[updateFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filters?.find((f) => f.type === 'dynamic')) {
|
if (!filters?.length) {
|
||||||
handleOnAdd();
|
handleOnAdd();
|
||||||
}
|
}
|
||||||
}, [filters, handleOnAdd]);
|
}, [filters, handleOnAdd]);
|
||||||
|
|
||||||
const dynamicFilters = filters?.filter((f) => f.type === 'dynamic');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.vertical}>
|
<div className={styles.vertical}>
|
||||||
{dynamicFilters?.map((f, i) => (
|
{filters?.map((f, i) => (
|
||||||
<div className={styles.horizontal} key={f.id}>
|
<div className={styles.horizontal} key={f.id}>
|
||||||
<SearchField
|
<SearchField
|
||||||
filter={f}
|
filter={f}
|
||||||
@ -61,8 +69,10 @@ const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError,
|
|||||||
tags={tags}
|
tags={tags}
|
||||||
isTagsLoading={isTagsLoading}
|
isTagsLoading={isTagsLoading}
|
||||||
deleteFilter={deleteFilter}
|
deleteFilter={deleteFilter}
|
||||||
|
allowDelete={true}
|
||||||
|
hideValue={hideValues}
|
||||||
/>
|
/>
|
||||||
{i === dynamicFilters.length - 1 && (
|
{i === filters.length - 1 && (
|
||||||
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
|
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@ -40,12 +40,25 @@ jest.mock('../language_provider', () => {
|
|||||||
describe('TraceQLSearch', () => {
|
describe('TraceQLSearch', () => {
|
||||||
let user: ReturnType<typeof userEvent.setup>;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
const datasource: TempoDatasource = {
|
||||||
|
search: {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: 'service-name',
|
||||||
|
tag: 'service.name',
|
||||||
|
operator: '=',
|
||||||
|
scope: TraceqlSearchScope.Resource,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as TempoDatasource;
|
||||||
|
|
||||||
let query: TempoQuery = {
|
let query: TempoQuery = {
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
queryType: 'traceqlSearch',
|
queryType: 'traceqlSearch',
|
||||||
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
|
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
|
||||||
query: '',
|
query: '',
|
||||||
filters: [{ id: 'min-duration', operator: '>', type: 'static', valueType: 'duration', tag: 'duration' }],
|
filters: [{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration' }],
|
||||||
};
|
};
|
||||||
const onChange = (q: TempoQuery) => {
|
const onChange = (q: TempoQuery) => {
|
||||||
query = q;
|
query = q;
|
||||||
@ -63,14 +76,11 @@ describe('TraceQLSearch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should update operator when new value is selected in operator input', async () => {
|
it('should update operator when new value is selected in operator input', async () => {
|
||||||
const { container } = render(
|
const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
|
||||||
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`);
|
const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`);
|
||||||
expect(minDurationOperator).not.toBeNull();
|
expect(minDurationOperator).not.toBeNull();
|
||||||
expect(minDurationOperator).toBeInTheDocument();
|
expect(minDurationOperator).toBeInTheDocument();
|
||||||
expect(await screen.findByText('>')).toBeInTheDocument();
|
|
||||||
|
|
||||||
if (minDurationOperator) {
|
if (minDurationOperator) {
|
||||||
await user.click(minDurationOperator);
|
await user.click(minDurationOperator);
|
||||||
@ -84,9 +94,7 @@ describe('TraceQLSearch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add new filter when new value is selected in the service name section', async () => {
|
it('should add new filter when new value is selected in the service name section', async () => {
|
||||||
const { container } = render(
|
const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
|
||||||
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
|
|
||||||
);
|
|
||||||
const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`);
|
const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`);
|
||||||
expect(serviceNameValue).not.toBeNull();
|
expect(serviceNameValue).not.toBeNull();
|
||||||
expect(serviceNameValue).toBeInTheDocument();
|
expect(serviceNameValue).toBeInTheDocument();
|
||||||
@ -106,28 +114,4 @@ describe('TraceQLSearch', () => {
|
|||||||
expect(nameFilter?.scope).toBe(TraceqlSearchScope.Resource);
|
expect(nameFilter?.scope).toBe(TraceqlSearchScope.Resource);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add new filter when new filter button is clicked and remove filter when remove button is clicked', async () => {
|
|
||||||
render(<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />);
|
|
||||||
|
|
||||||
const dynamicFilters = query.filters.filter((f) => f.type === 'dynamic');
|
|
||||||
expect(dynamicFilters.length).toBe(1);
|
|
||||||
const addButton = await screen.findByTitle('Add tag');
|
|
||||||
await user.click(addButton);
|
|
||||||
jest.advanceTimersByTime(1000);
|
|
||||||
|
|
||||||
// We have to rerender here so it picks up the new dynamic field
|
|
||||||
render(<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />);
|
|
||||||
|
|
||||||
const newDynamicFilters = query.filters.filter((f) => f.type === 'dynamic');
|
|
||||||
expect(newDynamicFilters.length).toBe(2);
|
|
||||||
|
|
||||||
const notInitialDynamic = newDynamicFilters.find((f) => f.id !== dynamicFilters[0].id);
|
|
||||||
const secondDynamicRemoveButton = await screen.findByLabelText(`remove tag with ID ${notInitialDynamic?.id}`);
|
|
||||||
await waitFor(() => expect(secondDynamicRemoveButton).toBeInTheDocument());
|
|
||||||
if (secondDynamicRemoveButton) {
|
|
||||||
await user.click(secondDynamicRemoveButton);
|
|
||||||
expect(query.filters.filter((f) => f.type === 'dynamic')).toStrictEqual(dynamicFilters);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { EditorRow } from '@grafana/experimental';
|
import { EditorRow } from '@grafana/experimental';
|
||||||
@ -10,7 +10,7 @@ import { createErrorNotification } from '../../../../core/copy/appNotification';
|
|||||||
import { notifyApp } from '../../../../core/reducers/appNotification';
|
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||||
import { dispatch } from '../../../../store/store';
|
import { dispatch } from '../../../../store/store';
|
||||||
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
|
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
|
||||||
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
import { TraceqlFilter } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
|
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
|
||||||
import { CompletionProvider } from '../traceql/autocomplete';
|
import { CompletionProvider } from '../traceql/autocomplete';
|
||||||
@ -21,7 +21,7 @@ import DurationInput from './DurationInput';
|
|||||||
import InlineSearchField from './InlineSearchField';
|
import InlineSearchField from './InlineSearchField';
|
||||||
import SearchField from './SearchField';
|
import SearchField from './SearchField';
|
||||||
import TagsInput from './TagsInput';
|
import TagsInput from './TagsInput';
|
||||||
import { generateQueryFromFilters, replaceAt } from './utils';
|
import { filterScopedTag, filterTitle, generateQueryFromFilters, replaceAt } from './utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
datasource: TempoDatasource;
|
datasource: TempoDatasource;
|
||||||
@ -38,18 +38,21 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
const [isTagsLoading, setIsTagsLoading] = useState(true);
|
const [isTagsLoading, setIsTagsLoading] = useState(true);
|
||||||
const [traceQlQuery, setTraceQlQuery] = useState<string>('');
|
const [traceQlQuery, setTraceQlQuery] = useState<string>('');
|
||||||
|
|
||||||
const updateFilter = (s: TraceqlFilter) => {
|
const updateFilter = useCallback(
|
||||||
const copy = { ...query };
|
(s: TraceqlFilter) => {
|
||||||
copy.filters ||= [];
|
const copy = { ...query };
|
||||||
const indexOfFilter = copy.filters.findIndex((f) => f.id === s.id);
|
copy.filters ||= [];
|
||||||
if (indexOfFilter >= 0) {
|
const indexOfFilter = copy.filters.findIndex((f) => f.id === s.id);
|
||||||
// update in place if the filter already exists, for consistency and to avoid UI bugs
|
if (indexOfFilter >= 0) {
|
||||||
copy.filters = replaceAt(copy.filters, indexOfFilter, s);
|
// update in place if the filter already exists, for consistency and to avoid UI bugs
|
||||||
} else {
|
copy.filters = replaceAt(copy.filters, indexOfFilter, s);
|
||||||
copy.filters.push(s);
|
} else {
|
||||||
}
|
copy.filters.push(s);
|
||||||
onChange(copy);
|
}
|
||||||
};
|
onChange(copy);
|
||||||
|
},
|
||||||
|
[onChange, query]
|
||||||
|
);
|
||||||
|
|
||||||
const deleteFilter = (s: TraceqlFilter) => {
|
const deleteFilter = (s: TraceqlFilter) => {
|
||||||
onChange({ ...query, filters: query.filters.filter((f) => f.id !== s.id) });
|
onChange({ ...query, filters: query.filters.filter((f) => f.id !== s.id) });
|
||||||
@ -59,7 +62,7 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
setTraceQlQuery(generateQueryFromFilters(query.filters || []));
|
setTraceQlQuery(generateQueryFromFilters(query.filters || []));
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
const findFilter = (id: string) => query.filters?.find((f) => f.id === id);
|
const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTags = async () => {
|
const fetchTags = async () => {
|
||||||
@ -85,43 +88,60 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
fetchTags();
|
fetchTags();
|
||||||
}, [datasource]);
|
}, [datasource]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize state with configured static filters that already have a value from the config
|
||||||
|
datasource.search?.filters
|
||||||
|
?.filter((f) => f.value)
|
||||||
|
.forEach((f) => {
|
||||||
|
if (!findFilter(f.id)) {
|
||||||
|
updateFilter(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [datasource.search?.filters, findFilter, updateFilter]);
|
||||||
|
|
||||||
|
// filter out tags that already exist in the static fields
|
||||||
|
const staticTags = datasource.search?.filters?.map((f) => f.tag) || [];
|
||||||
|
staticTags.push('duration');
|
||||||
|
const filteredTags = [...CompletionProvider.intrinsics, ...tags].filter((t) => !staticTags.includes(t));
|
||||||
|
|
||||||
|
// Dynamic filters are all filters that don't match the ID of a filter in the datasource configuration
|
||||||
|
// The duration tag is a special case since its selector is hard-coded
|
||||||
|
const dynamicFilters = (query.filters || []).filter(
|
||||||
|
(f) => f.tag !== 'duration' && (datasource.search?.filters?.findIndex((sf) => sf.id === f.id) || 0) === -1
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div>
|
<div>
|
||||||
<InlineSearchField label={'Service Name'}>
|
{datasource.search?.filters?.map((f) => (
|
||||||
<SearchField
|
<InlineSearchField
|
||||||
filter={
|
key={f.id}
|
||||||
findFilter('service-name') || {
|
label={filterTitle(f)}
|
||||||
id: 'service-name',
|
tooltip={`Filter your search by ${filterScopedTag(
|
||||||
type: 'static',
|
f
|
||||||
tag: 'service.name',
|
)}. To modify the default filters shown for search visit the Tempo datasource configuration page.`}
|
||||||
operator: '=',
|
>
|
||||||
scope: TraceqlSearchScope.Resource,
|
<SearchField
|
||||||
}
|
filter={findFilter(f.id) || f}
|
||||||
}
|
datasource={datasource}
|
||||||
datasource={datasource}
|
setError={setError}
|
||||||
setError={setError}
|
updateFilter={updateFilter}
|
||||||
updateFilter={updateFilter}
|
tags={[]}
|
||||||
tags={[]}
|
hideScope={true}
|
||||||
/>
|
hideTag={true}
|
||||||
</InlineSearchField>
|
/>
|
||||||
<InlineSearchField label={'Span Name'}>
|
</InlineSearchField>
|
||||||
<SearchField
|
))}
|
||||||
filter={findFilter('span-name') || { id: 'span-name', type: 'static', tag: 'name', operator: '=' }}
|
<InlineSearchField
|
||||||
datasource={datasource}
|
label={'Duration'}
|
||||||
setError={setError}
|
tooltip="The span duration, i.e. end - start time of the span. Accepted units are ns, ms, s, m, h"
|
||||||
updateFilter={updateFilter}
|
>
|
||||||
tags={[]}
|
|
||||||
/>
|
|
||||||
</InlineSearchField>
|
|
||||||
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">
|
|
||||||
<HorizontalGroup spacing={'sm'}>
|
<HorizontalGroup spacing={'sm'}>
|
||||||
<DurationInput
|
<DurationInput
|
||||||
filter={
|
filter={
|
||||||
findFilter('min-duration') || {
|
findFilter('min-duration') || {
|
||||||
id: 'min-duration',
|
id: 'min-duration',
|
||||||
type: 'static',
|
|
||||||
tag: 'duration',
|
tag: 'duration',
|
||||||
operator: '>',
|
operator: '>',
|
||||||
valueType: 'duration',
|
valueType: 'duration',
|
||||||
@ -134,7 +154,6 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
filter={
|
filter={
|
||||||
findFilter('max-duration') || {
|
findFilter('max-duration') || {
|
||||||
id: 'max-duration',
|
id: 'max-duration',
|
||||||
type: 'static',
|
|
||||||
tag: 'duration',
|
tag: 'duration',
|
||||||
operator: '<',
|
operator: '<',
|
||||||
valueType: 'duration',
|
valueType: 'duration',
|
||||||
@ -147,12 +166,12 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
</InlineSearchField>
|
</InlineSearchField>
|
||||||
<InlineSearchField label={'Tags'}>
|
<InlineSearchField label={'Tags'}>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
filters={query.filters}
|
filters={dynamicFilters}
|
||||||
datasource={datasource}
|
datasource={datasource}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
updateFilter={updateFilter}
|
updateFilter={updateFilter}
|
||||||
deleteFilter={deleteFilter}
|
deleteFilter={deleteFilter}
|
||||||
tags={[...CompletionProvider.intrinsics, ...tags]}
|
tags={filteredTags}
|
||||||
isTagsLoading={isTagsLoading}
|
isTagsLoading={isTagsLoading}
|
||||||
/>
|
/>
|
||||||
</InlineSearchField>
|
</InlineSearchField>
|
||||||
|
@ -8,51 +8,49 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a field without value', () => {
|
it('a field without value', () => {
|
||||||
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', operator: '=' }])).toBe('{}');
|
expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', operator: '=' }])).toBe('{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a field with value but without tag', () => {
|
it('a field with value but without tag', () => {
|
||||||
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', value: 'foovalue', operator: '=' }])).toBe('{}');
|
expect(generateQueryFromFilters([{ id: 'foo', value: 'foovalue', operator: '=' }])).toBe('{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a field with value and tag but without operator', () => {
|
it('a field with value and tag but without operator', () => {
|
||||||
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue' }])).toBe('{}');
|
expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue' }])).toBe('{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a field with tag, operator and tag', () => {
|
it('a field with tag, operator and tag', () => {
|
||||||
expect(
|
expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }])).toBe(
|
||||||
generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }])
|
'{.footag="foovalue"}'
|
||||||
).toBe('{.footag="foovalue"}');
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a field with valueType as integer', () => {
|
it('a field with valueType as integer', () => {
|
||||||
expect(
|
expect(
|
||||||
generateQueryFromFilters([
|
generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }])
|
||||||
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' },
|
|
||||||
])
|
|
||||||
).toBe('{.footag>1234}');
|
).toBe('{.footag>1234}');
|
||||||
});
|
});
|
||||||
it('two fields with everything filled in', () => {
|
it('two fields with everything filled in', () => {
|
||||||
expect(
|
expect(
|
||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
{ id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
{ id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
|
{ id: 'bar', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
|
||||||
])
|
])
|
||||||
).toBe('{.footag>=1234 && .bartag="barvalue"}');
|
).toBe('{.footag>=1234 && .bartag="barvalue"}');
|
||||||
});
|
});
|
||||||
it('two fields but one is missing a value', () => {
|
it('two fields but one is missing a value', () => {
|
||||||
expect(
|
expect(
|
||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
{ id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
|
{ id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' },
|
||||||
])
|
])
|
||||||
).toBe('{.footag>=1234}');
|
).toBe('{.footag>=1234}');
|
||||||
});
|
});
|
||||||
it('two fields but one is missing a value and the other a tag', () => {
|
it('two fields but one is missing a value and the other a tag', () => {
|
||||||
expect(
|
expect(
|
||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{ id: 'foo', type: 'static', value: '1234', operator: '>=', valueType: 'integer' },
|
{ id: 'foo', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
|
{ id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' },
|
||||||
])
|
])
|
||||||
).toBe('{}');
|
).toBe('{}');
|
||||||
});
|
});
|
||||||
@ -61,7 +59,6 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{
|
{
|
||||||
id: 'foo',
|
id: 'foo',
|
||||||
type: 'static',
|
|
||||||
tag: 'footag',
|
tag: 'footag',
|
||||||
value: '1234',
|
value: '1234',
|
||||||
operator: '>=',
|
operator: '>=',
|
||||||
@ -76,7 +73,6 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{
|
{
|
||||||
id: 'foo',
|
id: 'foo',
|
||||||
type: 'static',
|
|
||||||
tag: 'footag',
|
tag: 'footag',
|
||||||
value: '1234',
|
value: '1234',
|
||||||
operator: '>=',
|
operator: '>=',
|
||||||
@ -91,7 +87,6 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{
|
{
|
||||||
id: 'foo',
|
id: 'foo',
|
||||||
type: 'static',
|
|
||||||
tag: 'footag',
|
tag: 'footag',
|
||||||
value: '1234',
|
value: '1234',
|
||||||
operator: '>=',
|
operator: '>=',
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { startCase } from 'lodash';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
@ -19,7 +21,7 @@ const valueHelper = (f: TraceqlFilter) => {
|
|||||||
}
|
}
|
||||||
return f.value;
|
return f.value;
|
||||||
};
|
};
|
||||||
export const scopeHelper = (f: TraceqlFilter) => {
|
const scopeHelper = (f: TraceqlFilter) => {
|
||||||
// Intrinsic fields don't have a scope
|
// Intrinsic fields don't have a scope
|
||||||
if (CompletionProvider.intrinsics.find((t) => t === f.tag)) {
|
if (CompletionProvider.intrinsics.find((t) => t === f.tag)) {
|
||||||
return '';
|
return '';
|
||||||
@ -29,6 +31,18 @@ export const scopeHelper = (f: TraceqlFilter) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterScopedTag = (f: TraceqlFilter) => {
|
||||||
|
return scopeHelper(f) + f.tag;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterTitle = (f: TraceqlFilter) => {
|
||||||
|
// Special case for the intrinsic "name" since a label called "Name" isn't explicit
|
||||||
|
if (f.tag === 'name') {
|
||||||
|
return 'Span Name';
|
||||||
|
}
|
||||||
|
return startCase(filterScopedTag(f));
|
||||||
|
};
|
||||||
|
|
||||||
export function replaceAt<T>(array: T[], index: number, value: T) {
|
export function replaceAt<T>(array: T[], index: number, value: T) {
|
||||||
const ret = array.slice(0);
|
const ret = array.slice(0);
|
||||||
ret[index] = value;
|
ret[index] = value;
|
||||||
|
@ -12,6 +12,7 @@ import { LokiSearchSettings } from './LokiSearchSettings';
|
|||||||
import { QuerySettings } from './QuerySettings';
|
import { QuerySettings } from './QuerySettings';
|
||||||
import { SearchSettings } from './SearchSettings';
|
import { SearchSettings } from './SearchSettings';
|
||||||
import { ServiceGraphSettings } from './ServiceGraphSettings';
|
import { ServiceGraphSettings } from './ServiceGraphSettings';
|
||||||
|
import { TraceQLSearchSettings } from './TraceQLSearchSettings';
|
||||||
|
|
||||||
export type Props = DataSourcePluginOptionsEditorProps;
|
export type Props = DataSourcePluginOptionsEditorProps;
|
||||||
|
|
||||||
@ -48,7 +49,11 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gf-form-group">
|
<div className="gf-form-group">
|
||||||
<SearchSettings options={options} onOptionsChange={onOptionsChange} />
|
{config.featureToggles.traceqlSearch ? (
|
||||||
|
<TraceQLSearchSettings options={options} onOptionsChange={onOptionsChange} />
|
||||||
|
) : (
|
||||||
|
<SearchSettings options={options} onOptionsChange={onOptionsChange} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gf-form-group">
|
<div className="gf-form-group">
|
||||||
|
@ -0,0 +1,59 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
import useAsync from 'react-use/lib/useAsync';
|
||||||
|
|
||||||
|
import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data';
|
||||||
|
import { getDataSourceSrv } from '@grafana/runtime';
|
||||||
|
import { InlineField, InlineFieldRow, InlineSwitch } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { TempoDatasource } from '../datasource';
|
||||||
|
import { TempoJsonData } from '../types';
|
||||||
|
|
||||||
|
import { TraceQLSearchTags } from './TraceQLSearchTags';
|
||||||
|
|
||||||
|
interface Props extends DataSourcePluginOptionsEditorProps<TempoJsonData> {}
|
||||||
|
|
||||||
|
export function TraceQLSearchSettings({ options, onOptionsChange }: Props) {
|
||||||
|
const dataSourceSrv = getDataSourceSrv();
|
||||||
|
const fetchDatasource = async () => {
|
||||||
|
return (await dataSourceSrv.get({ type: options.type, uid: options.uid })) as TempoDatasource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { value: datasource } = useAsync(fetchDatasource, [dataSourceSrv, options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h3 className="page-heading">Tempo search</h3>
|
||||||
|
<InlineFieldRow className={styles.row}>
|
||||||
|
<InlineField tooltip="Removes the search tab from the query editor" label="Hide search" labelWidth={26}>
|
||||||
|
<InlineSwitch
|
||||||
|
id="hideSearch"
|
||||||
|
value={options.jsonData.search?.hide}
|
||||||
|
onChange={(event: React.SyntheticEvent<HTMLInputElement>) =>
|
||||||
|
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', {
|
||||||
|
...options.jsonData.search,
|
||||||
|
hide: event.currentTarget.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
<InlineFieldRow className={styles.row}>
|
||||||
|
<InlineField tooltip="Configures which fields are available in the UI" label="Static filters" labelWidth={26}>
|
||||||
|
<TraceQLSearchTags datasource={datasource} options={options} onOptionsChange={onOptionsChange} />
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: css`
|
||||||
|
label: container;
|
||||||
|
width: 100%;
|
||||||
|
`,
|
||||||
|
row: css`
|
||||||
|
label: row;
|
||||||
|
align-items: baseline;
|
||||||
|
`,
|
||||||
|
};
|
@ -0,0 +1,111 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import useAsync from 'react-use/lib/useAsync';
|
||||||
|
|
||||||
|
import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data';
|
||||||
|
import { Alert } from '@grafana/ui';
|
||||||
|
|
||||||
|
import TagsInput from '../SearchTraceQLEditor/TagsInput';
|
||||||
|
import { replaceAt } from '../SearchTraceQLEditor/utils';
|
||||||
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
|
import { TempoDatasource } from '../datasource';
|
||||||
|
import { CompletionProvider } from '../traceql/autocomplete';
|
||||||
|
import { TempoJsonData } from '../types';
|
||||||
|
|
||||||
|
interface Props extends DataSourcePluginOptionsEditorProps<TempoJsonData> {
|
||||||
|
datasource?: TempoDatasource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceQLSearchTags({ options, onOptionsChange, datasource }: Props) {
|
||||||
|
const fetchTags = async () => {
|
||||||
|
if (!datasource) {
|
||||||
|
throw new Error('Unable to retrieve datasource');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await datasource.languageProvider.start();
|
||||||
|
const tags = datasource.languageProvider.getTags();
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
// This is needed because the /api/v2/search/tag/${tag}/values API expects "status" and the v1 API expects "status.code"
|
||||||
|
// so Tempo doesn't send anything and we inject it here for the autocomplete
|
||||||
|
if (!tags.find((t) => t === 'status')) {
|
||||||
|
tags.push('status');
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// @ts-ignore
|
||||||
|
throw new Error(`${e.statusText}: ${e.data.error}`);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error, loading, value: tags } = useAsync(fetchTags, [datasource, options]);
|
||||||
|
|
||||||
|
const updateFilter = useCallback(
|
||||||
|
(s: TraceqlFilter) => {
|
||||||
|
let copy = options.jsonData.search?.filters;
|
||||||
|
copy ||= [];
|
||||||
|
const indexOfFilter = copy.findIndex((f) => f.id === s.id);
|
||||||
|
if (indexOfFilter >= 0) {
|
||||||
|
// update in place if the filter already exists, for consistency and to avoid UI bugs
|
||||||
|
copy = replaceAt(copy, indexOfFilter, s);
|
||||||
|
} else {
|
||||||
|
copy.push(s);
|
||||||
|
}
|
||||||
|
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', {
|
||||||
|
...options.jsonData.search,
|
||||||
|
filters: copy,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onOptionsChange, options]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteFilter = (s: TraceqlFilter) => {
|
||||||
|
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', {
|
||||||
|
...options.jsonData.search,
|
||||||
|
filters: options.jsonData.search?.filters?.filter((f) => f.id !== s.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!options.jsonData.search?.filters) {
|
||||||
|
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', {
|
||||||
|
...options.jsonData.search,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: 'service-name',
|
||||||
|
tag: 'service.name',
|
||||||
|
operator: '=',
|
||||||
|
scope: TraceqlSearchScope.Resource,
|
||||||
|
},
|
||||||
|
{ id: 'span-name', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [onOptionsChange, options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{datasource ? (
|
||||||
|
<TagsInput
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
deleteFilter={deleteFilter}
|
||||||
|
filters={options.jsonData.search?.filters || []}
|
||||||
|
datasource={datasource}
|
||||||
|
setError={() => {}}
|
||||||
|
tags={[...CompletionProvider.intrinsics, ...(tags || [])]}
|
||||||
|
isTagsLoading={loading}
|
||||||
|
hideValues={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>Invalid data source, please create a valid data source and try again</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert title={'Unable to fetch TraceQL tags'} severity={'error'} topSpacing={1}>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -54,13 +54,10 @@ composableKinds: DataQuery: {
|
|||||||
#TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "clear" @cuetsy(kind="type")
|
#TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "clear" @cuetsy(kind="type")
|
||||||
|
|
||||||
// static fields are pre-set in the UI, dynamic fields are added by the user
|
// static fields are pre-set in the UI, dynamic fields are added by the user
|
||||||
#TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type")
|
#TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum")
|
||||||
#TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum")
|
|
||||||
#TraceqlFilter: {
|
#TraceqlFilter: {
|
||||||
// Uniquely identify the filter, will not be used in the query generation
|
// Uniquely identify the filter, will not be used in the query generation
|
||||||
id: string
|
id: string
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
|
||||||
type: #TraceqlSearchFilterType
|
|
||||||
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
tag?: string
|
tag?: string
|
||||||
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||||
|
@ -60,8 +60,6 @@ export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceM
|
|||||||
/**
|
/**
|
||||||
* static fields are pre-set in the UI, dynamic fields are added by the user
|
* static fields are pre-set in the UI, dynamic fields are added by the user
|
||||||
*/
|
*/
|
||||||
export type TraceqlSearchFilterType = ('static' | 'dynamic');
|
|
||||||
|
|
||||||
export enum TraceqlSearchScope {
|
export enum TraceqlSearchScope {
|
||||||
Resource = 'resource',
|
Resource = 'resource',
|
||||||
Span = 'span',
|
Span = 'span',
|
||||||
@ -85,10 +83,6 @@ export interface TraceqlFilter {
|
|||||||
* The tag for the search filter, for example: .http.status_code, .service.name, status
|
* The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
*/
|
*/
|
||||||
tag?: string;
|
tag?: string;
|
||||||
/**
|
|
||||||
* The type of the filter, can either be static (pre defined in the UI) or dynamic
|
|
||||||
*/
|
|
||||||
type: TraceqlSearchFilterType;
|
|
||||||
/**
|
/**
|
||||||
* The value for the search filter
|
* The value for the search filter
|
||||||
*/
|
*/
|
||||||
|
@ -36,6 +36,7 @@ import { PrometheusDatasource } from '../prometheus/datasource';
|
|||||||
import { PromQuery } from '../prometheus/types';
|
import { PromQuery } from '../prometheus/types';
|
||||||
|
|
||||||
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
|
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
|
||||||
|
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
|
||||||
import {
|
import {
|
||||||
failedMetric,
|
failedMetric,
|
||||||
histogramMetric,
|
histogramMetric,
|
||||||
@ -66,6 +67,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
};
|
};
|
||||||
search?: {
|
search?: {
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
filters?: TraceqlFilter[];
|
||||||
};
|
};
|
||||||
nodeGraph?: NodeGraphOptions;
|
nodeGraph?: NodeGraphOptions;
|
||||||
lokiSearch?: {
|
lokiSearch?: {
|
||||||
@ -92,6 +94,20 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
this.lokiSearch = instanceSettings.jsonData.lokiSearch;
|
this.lokiSearch = instanceSettings.jsonData.lokiSearch;
|
||||||
this.traceQuery = instanceSettings.jsonData.traceQuery;
|
this.traceQuery = instanceSettings.jsonData.traceQuery;
|
||||||
this.languageProvider = new TempoLanguageProvider(this);
|
this.languageProvider = new TempoLanguageProvider(this);
|
||||||
|
if (!this.search?.filters) {
|
||||||
|
this.search = {
|
||||||
|
...this.search,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: 'service-name',
|
||||||
|
tag: 'service.name',
|
||||||
|
operator: '=',
|
||||||
|
scope: TraceqlSearchScope.Resource,
|
||||||
|
},
|
||||||
|
{ id: 'span-name', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query(options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> {
|
query(options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> {
|
||||||
|
@ -4,7 +4,7 @@ import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsS
|
|||||||
|
|
||||||
import { LokiQuery } from '../loki/types';
|
import { LokiQuery } from '../loki/types';
|
||||||
|
|
||||||
import { TempoQuery as TempoBase, TempoQueryType } from './dataquery.gen';
|
import { TempoQuery as TempoBase, TempoQueryType, TraceqlFilter } from './dataquery.gen';
|
||||||
|
|
||||||
export interface SearchQueryParams {
|
export interface SearchQueryParams {
|
||||||
minDuration?: string;
|
minDuration?: string;
|
||||||
@ -22,6 +22,7 @@ export interface TempoJsonData extends DataSourceJsonData {
|
|||||||
};
|
};
|
||||||
search?: {
|
search?: {
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
filters?: TraceqlFilter[];
|
||||||
};
|
};
|
||||||
nodeGraph?: NodeGraphOptions;
|
nodeGraph?: NodeGraphOptions;
|
||||||
lokiSearch?: {
|
lokiSearch?: {
|
||||||
|
Loading…
Reference in New Issue
Block a user