From fd37ff29b57520036fdc02f8a93a7d74770fd153 Mon Sep 17 00:00:00 2001 From: Andre Pereira Date: Mon, 6 Mar 2023 16:31:08 +0000 Subject: [PATCH] Tempo: New Search UI using TraceQL (#63808) * WIP of creating new components to support the Search tab using TraceQL * Search fields now require an ID. Added duration fields to new Search UI * Distinguish static from dynamic fields. Added dynamic tags input * Moved new search behind traceqlSearch feature flag. Added handling of different types of values to accurately wrap them in quotes when generating query. * Hold search state in TempoQuery to leverage state in URL. Moved types to schema file * Use a read only monaco editor to render a syntax highlighted generated query. Added tooltip to duration. Added query options section * Support multiple values using the regex operator and multi input * Delete dynamic filters * Automatically select the regex op when multiple values are selected. Revert to previous operator when only one value is selected * Added tests for SearchField component * Added tests for the TraceQLSearch component * Added tests for function that generates the query * Fix merge conflicts * Update test * Replace Search tab when traceqlSearch feature flag is enabled. Limit operators for both name fields to =,!=,=~ * Disable clear button for values * Changed delete and add buttons to AccessoryButton. Added descriptions to operators * Remove duplicate test * Added a prismjs grammar for traceql. Replaced read only query editor with syntax highlighted query. Removed spaces between tag operator and value when generating query. * Fix support for custom values when isMulti is enabled in Select * Use toOption function --- .../tempodataquery/schema-reference.md | 33 +-- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + .../src/components/Select/SelectBase.tsx | 14 +- pkg/services/featuremgmt/registry.go | 6 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../kinds/dataquery/types_dataquery_gen.go | 85 +++++++- pkg/tsdb/tempo/tempo.go | 2 +- .../tempo/QueryEditor/QueryField.tsx | 17 +- .../SearchTraceQLEditor/DurationInput.tsx | 43 ++++ .../SearchTraceQLEditor/InlineSearchField.tsx | 20 ++ .../SearchTraceQLEditor/SearchField.test.tsx | 170 ++++++++++++++++ .../tempo/SearchTraceQLEditor/SearchField.tsx | 166 +++++++++++++++ .../tempo/SearchTraceQLEditor/TagsInput.tsx | 58 ++++++ .../TraceQLSearch.test.tsx | 131 ++++++++++++ .../SearchTraceQLEditor/TraceQLSearch.tsx | 190 ++++++++++++++++++ .../tempo/SearchTraceQLEditor/utils.test.ts | 57 ++++++ .../tempo/SearchTraceQLEditor/utils.ts | 57 ++++++ .../plugins/datasource/tempo/dataquery.cue | 56 ++++-- .../plugins/datasource/tempo/dataquery.gen.ts | 51 ++++- .../datasource/tempo/datasource.test.ts | 5 + .../plugins/datasource/tempo/datasource.ts | 31 +++ .../tempo/traceql/TraceQLEditor.tsx | 10 +- .../datasource/tempo/traceql/traceql.ts | 35 +++- public/app/plugins/datasource/tempo/types.ts | 2 +- 25 files changed, 1171 insertions(+), 74 deletions(-) create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts diff --git a/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md b/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md index 252599686a4..a16582d8e3d 100644 --- a/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md +++ b/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md @@ -13,36 +13,7 @@ title: TempoDataQuery kind -It extends [DataQuery](#dataquery). - -| Property | Type | Required | Description | -|-------------------|---------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `query` | string | **Yes** | TraceQL query or trace ID | -| `refId` | string | **Yes** | *(Inherited from [DataQuery](#dataquery))*
A - Z | -| `datasource` | | No | *(Inherited from [DataQuery](#dataquery))*
For mixed data sources the selected datasource is on the query level.
For non mixed scenarios this is undefined.
TODO find a better way to do this ^ that's friendly to schema
TODO this shouldn't be unknown but DataSourceRef | null | -| `hide` | boolean | No | *(Inherited from [DataQuery](#dataquery))*
true if query is disabled (ie should not be returned to the dashboard) | -| `key` | string | No | *(Inherited from [DataQuery](#dataquery))*
Unique, guid like, string used in explore mode | -| `limit` | integer | No | Defines the maximum number of traces that are returned from Tempo | -| `maxDuration` | string | No | Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms | -| `minDuration` | string | No | Define the minimum duration to select traces. Use duration format, for example: 1.2s, 100ms | -| `queryType` | string | No | *(Inherited from [DataQuery](#dataquery))*
Specify the query flavor
TODO make this required and give it a default | -| `search` | string | No | Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true | -| `serviceMapQuery` | string | No | Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"} | -| `serviceName` | string | No | Query traces by service name | -| `spanName` | string | No | Query traces by span name | - -### DataQuery - -These are the common properties available to all queries in all datasources. -Specific implementations will *extend* this interface, adding the required -properties for the given context. - -| Property | Type | Required | Description | -|--------------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `refId` | string | **Yes** | A - Z | -| `datasource` | | No | For mixed data sources the selected datasource is on the query level.
For non mixed scenarios this is undefined.
TODO find a better way to do this ^ that's friendly to schema
TODO this shouldn't be unknown but DataSourceRef | null | -| `hide` | boolean | No | true if query is disabled (ie should not be returned to the dashboard) | -| `key` | string | No | Unique, guid like, string used in explore mode | -| `queryType` | string | No | Specify the query flavor
TODO make this required and give it a default | +| Property | Type | Required | Description | +|----------|------|----------|-------------| diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 9353b6202aa..776b5ca774e 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -91,6 +91,7 @@ Alpha features might be changed or removed without prior notice. | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | | `individualCookiePreferences` | Support overriding cookie preferences per user | | `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. | +| `traceqlSearch` | Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3df66b76d6b..f5688619173 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -80,4 +80,5 @@ export interface FeatureToggles { lokiQuerySplitting?: boolean; individualCookiePreferences?: boolean; drawerDataSourcePicker?: boolean; + traceqlSearch?: boolean; } diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index 0a19c5f7c01..3241839dbf9 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -4,7 +4,7 @@ import { default as ReactAsyncSelect } from 'react-select/async'; import { default as AsyncCreatable } from 'react-select/async-creatable'; import Creatable from 'react-select/creatable'; -import { SelectableValue } from '@grafana/data'; +import { SelectableValue, toOption } from '@grafana/data'; import { useTheme2 } from '../../themes'; import { Icon } from '../Icon/Icon'; @@ -190,8 +190,16 @@ export function SelectBase({ // If option is passed as a plain value (value property from SelectableValue property) // we are selecting the corresponding value from the options if (isMulti && value && Array.isArray(value) && !loadOptions) { - // @ts-ignore - selectedValue = value.map((v) => findSelectedValue(v.value ?? v, options)); + selectedValue = value.map((v) => { + // @ts-ignore + const selectableValue = findSelectedValue(v.value ?? v, options); + // If the select allows custom values there likely won't be a selectableValue in options + // so we must return a new selectableValue + if (!allowCustomValue || selectableValue) { + return selectableValue; + } + return typeof v === 'string' ? toOption(v) : v; + }); } else if (loadOptions) { const hasValue = defaultValue || value; selectedValue = hasValue ? [hasValue] : []; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b8f15a158ad..57fb12c13f1 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -362,5 +362,11 @@ var ( State: FeatureStateAlpha, FrontendOnly: true, }, + { + Name: "traceqlSearch", + Description: "Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries", + State: FeatureStateAlpha, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 63e177e54a9..c26697b1a81 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -262,4 +262,8 @@ const ( // FlagDrawerDataSourcePicker // Changes the user experience for data source selection to a drawer. FlagDrawerDataSourcePicker = "drawerDataSourcePicker" + + // FlagTraceqlSearch + // Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries + FlagTraceqlSearch = "traceqlSearch" ) diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index 83c2d3ff851..06fb1fb74e2 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -9,23 +9,64 @@ package dataquery +// Defines values for TempoQueryFiltersType. +const ( + TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic" + TempoQueryFiltersTypeStatic TempoQueryFiltersType = "static" +) + // Defines values for TempoQueryType. const ( - TempoQueryTypeClear TempoQueryType = "clear" - TempoQueryTypeNativeSearch TempoQueryType = "nativeSearch" - TempoQueryTypeSearch TempoQueryType = "search" - TempoQueryTypeServiceMap TempoQueryType = "serviceMap" - TempoQueryTypeTraceql TempoQueryType = "traceql" - TempoQueryTypeUpload TempoQueryType = "upload" + TempoQueryTypeClear TempoQueryType = "clear" + TempoQueryTypeNativeSearch TempoQueryType = "nativeSearch" + TempoQueryTypeSearch TempoQueryType = "search" + TempoQueryTypeServiceMap TempoQueryType = "serviceMap" + TempoQueryTypeTraceql TempoQueryType = "traceql" + TempoQueryTypeTraceqlSearch TempoQueryType = "traceqlSearch" + TempoQueryTypeUpload TempoQueryType = "upload" +) + +// Defines values for TraceqlFilterType. +const ( + TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic" + TraceqlFilterTypeStatic TraceqlFilterType = "static" +) + +// Defines values for TraceqlSearchFilterType. +const ( + TraceqlSearchFilterTypeDynamic TraceqlSearchFilterType = "dynamic" + TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static" ) // TempoDataQuery defines model for TempoDataQuery. -type TempoDataQuery struct { +type TempoDataQuery = map[string]interface{} + +// TempoQuery defines model for TempoQuery. +type TempoQuery struct { // For mixed data sources the selected datasource is on the query level. // For non mixed scenarios this is undefined. // TODO find a better way to do this ^ that's friendly to schema // TODO this shouldn't be unknown but DataSourceRef | null Datasource *interface{} `json:"datasource,omitempty"` + Filters []struct { + // Uniquely identify the filter, will not be used in the query generation + Id string `json:"id"` + + // The operator that connects the tag to the value, for example: =, >, !=, =~ + Operator *string `json:"operator,omitempty"` + + // The tag for the search filter, for example: .http.status_code, .service.name, status + 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 + Value *interface{} `json:"value,omitempty"` + + // The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query + ValueType *string `json:"valueType,omitempty"` + } `json:"filters"` // Hide true if query is disabled (ie should not be returned to the dashboard) Hide *bool `json:"hide,omitempty"` @@ -65,5 +106,35 @@ type TempoDataQuery struct { SpanName *string `json:"spanName,omitempty"` } +// 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 type TempoQueryType string + +// TraceqlFilter defines model for TraceqlFilter. +type TraceqlFilter struct { + // Uniquely identify the filter, will not be used in the query generation + Id string `json:"id"` + + // The operator that connects the tag to the value, for example: =, >, !=, =~ + Operator *string `json:"operator,omitempty"` + + // The tag for the search filter, for example: .http.status_code, .service.name, status + 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 + Value *interface{} `json:"value,omitempty"` + + // The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query + ValueType *string `json:"valueType,omitempty"` +} + +// The type of the filter, can either be static (pre defined in the UI) or dynamic +type TraceqlFilterType string + +// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user +type TraceqlSearchFilterType string diff --git a/pkg/tsdb/tempo/tempo.go b/pkg/tsdb/tempo/tempo.go index f52ec3b2c44..752c9d34a1f 100644 --- a/pkg/tsdb/tempo/tempo.go +++ b/pkg/tsdb/tempo/tempo.go @@ -61,7 +61,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) queryRes := backend.DataResponse{} refID := req.Queries[0].RefID - model := &dataquery.TempoDataQuery{} + model := &dataquery.TempoQuery{} err := json.Unmarshal(req.Queries[0].JSON, model) if err != nil { return result, err diff --git a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx index e8dc49370ce..2bcc6650fa1 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx @@ -17,6 +17,7 @@ import { import { LokiQueryField } from '../../loki/components/LokiQueryField'; import { LokiDatasource } from '../../loki/datasource'; import { LokiQuery } from '../../loki/types'; +import TraceQLSearch from '../SearchTraceQLEditor/TraceQLSearch'; import { TempoQueryType } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import { QueryEditor } from '../traceql/QueryEditor'; @@ -28,7 +29,7 @@ import { getDS } from './utils'; interface Props extends QueryEditorProps, Themeable2 {} -const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceql'; +const DEFAULT_QUERY_TYPE: TempoQueryType = config.featureToggles.traceqlSearch ? 'traceqlSearch' : 'traceql'; class TempoQueryFieldComponent extends React.PureComponent { constructor(props: Props) { @@ -83,7 +84,11 @@ class TempoQueryFieldComponent extends React.PureComponent { { value: 'serviceMap', label: 'Service Graph' }, ]; - if (!datasource?.search?.hide) { + if (config.featureToggles.traceqlSearch) { + queryTypeOptions.unshift({ value: 'traceqlSearch', label: 'Search' }); + } + + if (!config.featureToggles.traceqlSearch && !datasource?.search?.hide) { queryTypeOptions.unshift({ value: 'nativeSearch', label: 'Search' }); } @@ -141,6 +146,14 @@ class TempoQueryFieldComponent extends React.PureComponent { onRunQuery={this.props.onRunQuery} /> )} + {query.queryType === 'traceqlSearch' && ( + + )} {query.queryType === 'upload' && (
void; + isTagsLoading?: boolean; + operators: string[]; +} +const DurationInput = ({ filter, operators, updateFilter }: Props) => { + return ( + + { + updateFilter({ ...filter, value: v.currentTarget.value }); + }} + placeholder="e.g. 100ms, 1.2s" + aria-label={`select ${filter.id} value`} + width={18} + /> + + ); +}; + +export default DurationInput; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx new file mode 100644 index 00000000000..46d4855e635 --- /dev/null +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +import { InlineFieldRow, InlineField } from '@grafana/ui'; + +interface Props { + label: string; + tooltip?: string; + children: React.ReactElement; +} +const SearchField: FC = ({ label, tooltip, children }) => { + return ( + + + {children} + + + ); +}; + +export default SearchField; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx new file mode 100644 index 00000000000..a8d472b5c44 --- /dev/null +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx @@ -0,0 +1,170 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { FetchError } from '@grafana/runtime'; + +import { TraceqlFilter } from '../dataquery.gen'; +import { TempoDatasource } from '../datasource'; + +import SearchField from './SearchField'; + +const getOptionsV2 = jest.fn().mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + value: 'customer', + label: 'customer', + type: 'string', + }, + { + value: 'driver', + label: 'driver', + type: 'string', + }, + ]); + }, 1000); + }); +}); + +jest.mock('../language_provider', () => { + return jest.fn().mockImplementation(() => { + return { getOptionsV2 }; + }); +}); + +describe('SearchField', () => { + let user: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + // Need to use delay: null here to work with fakeTimers + // see https://github.com/testing-library/user-event/issues/833 + user = userEvent.setup({ delay: null }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should not render tag if tag is present in field', () => { + const updateFilter = jest.fn((val) => { + return val; + }); + const filter: TraceqlFilter = { id: 'test1', type: 'static', valueType: 'string', tag: 'test-tag' }; + const { container } = renderSearchField(updateFilter, filter); + + 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 value"]`)).toBeInTheDocument(); + }); + + it('should update operator when new value is selected in operator input', async () => { + const updateFilter = jest.fn((val) => { + return val; + }); + const filter: TraceqlFilter = { id: 'test1', operator: '=', type: 'static', valueType: 'string', tag: 'test-tag' }; + const { container } = renderSearchField(updateFilter, filter); + + const select = await container.querySelector(`input[aria-label="select test1 operator"]`); + expect(select).not.toBeNull(); + expect(select).toBeInTheDocument(); + if (select) { + await user.click(select); + jest.advanceTimersByTime(1000); + const largerThanOp = await screen.findByText('>'); + await user.click(largerThanOp); + + expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '>' }); + } + }); + + it('should update value when new value is selected in value input', async () => { + const updateFilter = jest.fn((val) => { + return val; + }); + const filter: TraceqlFilter = { + id: 'test1', + value: 'old', + type: 'static', + valueType: 'string', + tag: 'test-tag', + }; + const { container } = renderSearchField(updateFilter, filter); + + const select = await container.querySelector(`input[aria-label="select test1 value"]`); + expect(select).not.toBeNull(); + expect(select).toBeInTheDocument(); + if (select) { + // Add first value + await user.click(select); + jest.advanceTimersByTime(1000); + const driverVal = await screen.findByText('driver'); + await user.click(driverVal); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['driver'] }); + + // Add a second value + await user.click(select); + jest.advanceTimersByTime(1000); + const customerVal = await screen.findByText('customer'); + await user.click(customerVal); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['driver', 'customer'] }); + + // Remove the first value + const firstValRemove = await screen.findByLabelText('Remove driver'); + await user.click(firstValRemove); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['customer'] }); + } + }); + + it('should update tag when new value is selected in tag input', async () => { + const updateFilter = jest.fn((val) => { + return val; + }); + const filter: TraceqlFilter = { + id: 'test1', + type: 'dynamic', + valueType: 'string', + }; + const { container } = renderSearchField(updateFilter, filter, ['tag1', 'tag22', 'tag33']); + + const select = await container.querySelector(`input[aria-label="select test1 tag"]`); + expect(select).not.toBeNull(); + expect(select).toBeInTheDocument(); + if (select) { + // Select tag22 as the tag + await user.click(select); + jest.advanceTimersByTime(1000); + const tag22 = await screen.findByText('tag22'); + await user.click(tag22); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag22' }); + + // Select tag1 as the tag + await user.click(select); + jest.advanceTimersByTime(1000); + const tag1 = await screen.findByText('tag1'); + await user.click(tag1); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag1' }); + + // Remove the tag + const tagRemove = await screen.findByLabelText('select-clear-value'); + await user.click(tagRemove); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: undefined }); + } + }); +}); + +const renderSearchField = (updateFilter: (f: TraceqlFilter) => void, filter: TraceqlFilter, tags?: string[]) => { + return render( + + ); +}; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx new file mode 100644 index 00000000000..cbdc6fa5eda --- /dev/null +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { AccessoryButton } from '@grafana/experimental'; +import { FetchError, isFetchError } from '@grafana/runtime'; +import { Select, HorizontalGroup } from '@grafana/ui'; + +import { createErrorNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; +import { dispatch } from '../../../../store/store'; +import { TraceqlFilter } from '../dataquery.gen'; +import { TempoDatasource } from '../datasource'; +import TempoLanguageProvider from '../language_provider'; +import { operators as allOperators } from '../traceql/traceql'; + +import { operatorSelectableValue } from './utils'; + +interface Props { + filter: TraceqlFilter; + datasource: TempoDatasource; + updateFilter: (f: TraceqlFilter) => void; + deleteFilter?: (f: TraceqlFilter) => void; + setError: (error: FetchError) => void; + isTagsLoading?: boolean; + tags: string[]; + operators?: string[]; +} +const SearchField = ({ + filter, + datasource, + updateFilter, + deleteFilter, + isTagsLoading, + tags, + setError, + operators, +}: Props) => { + const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); + const [isLoadingValues, setIsLoadingValues] = useState(false); + const [options, setOptions] = useState>>([]); + // 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 + // there's only one value selected, so we store the previous operator and value + const [prevOperator, setPrevOperator] = useState(filter.operator); + const [prevValue, setPrevValue] = useState(filter.value); + + useEffect(() => { + if (Array.isArray(filter.value) && filter.value.length > 1 && filter.operator !== '=~') { + setPrevOperator(filter.operator); + updateFilter({ ...filter, operator: '=~' }); + } + if (Array.isArray(filter.value) && filter.value.length <= 1 && (prevValue?.length || 0) > 1) { + updateFilter({ ...filter, operator: prevOperator, value: filter.value[0] }); + } + }, [prevValue, prevOperator, updateFilter, filter]); + + useEffect(() => { + setPrevValue(filter.value); + }, [filter.value]); + + const loadOptions = useCallback( + async (name: string) => { + setIsLoadingValues(true); + + try { + const options = await languageProvider.getOptionsV2(name); + return options; + } catch (error) { + if (isFetchError(error) && error?.status === 404) { + setError(error); + } else if (error instanceof Error) { + dispatch(notifyApp(createErrorNotification('Error', error))); + } + return []; + } finally { + setIsLoadingValues(false); + } + }, + [setError, languageProvider] + ); + + useEffect(() => { + const fetchOptions = async () => { + try { + if (filter.tag) { + setOptions(await loadOptions(filter.tag)); + } + } 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))); + } + } + }; + fetchOptions(); + }, [languageProvider, loadOptions, setError, filter.tag]); + + return ( + + {filter.type === 'dynamic' && ( + { + updateFilter({ ...filter, operator: v?.value }); + }} + isClearable={false} + aria-label={`select ${filter.id} operator`} + allowCustomValue={true} + width={8} + /> +