mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
5db0d14606
commit
fd37ff29b5
@ -13,36 +13,7 @@ title: TempoDataQuery kind
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
It extends [DataQuery](#dataquery).
|
|
||||||
|
|
||||||
| Property | Type | Required | Description |
|
| Property | Type | Required | Description |
|
||||||
|-------------------|---------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|----------|------|----------|-------------|
|
||||||
| `query` | string | **Yes** | TraceQL query or trace ID |
|
|
||||||
| `refId` | string | **Yes** | *(Inherited from [DataQuery](#dataquery))*<br/>A - Z |
|
|
||||||
| `datasource` | | No | *(Inherited from [DataQuery](#dataquery))*<br/>For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null |
|
|
||||||
| `hide` | boolean | No | *(Inherited from [DataQuery](#dataquery))*<br/>true if query is disabled (ie should not be returned to the dashboard) |
|
|
||||||
| `key` | string | No | *(Inherited from [DataQuery](#dataquery))*<br/>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))*<br/>Specify the query flavor<br/>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.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>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<br/>TODO make this required and give it a default |
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 |
|
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals |
|
||||||
| `individualCookiePreferences` | Support overriding cookie preferences per user |
|
| `individualCookiePreferences` | Support overriding cookie preferences per user |
|
||||||
| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. |
|
| `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
|
## Development feature toggles
|
||||||
|
|
||||||
|
@ -80,4 +80,5 @@ export interface FeatureToggles {
|
|||||||
lokiQuerySplitting?: boolean;
|
lokiQuerySplitting?: boolean;
|
||||||
individualCookiePreferences?: boolean;
|
individualCookiePreferences?: boolean;
|
||||||
drawerDataSourcePicker?: boolean;
|
drawerDataSourcePicker?: boolean;
|
||||||
|
traceqlSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { default as ReactAsyncSelect } from 'react-select/async';
|
|||||||
import { default as AsyncCreatable } from 'react-select/async-creatable';
|
import { default as AsyncCreatable } from 'react-select/async-creatable';
|
||||||
import Creatable from 'react-select/creatable';
|
import Creatable from 'react-select/creatable';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue, toOption } from '@grafana/data';
|
||||||
|
|
||||||
import { useTheme2 } from '../../themes';
|
import { useTheme2 } from '../../themes';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
@ -190,8 +190,16 @@ export function SelectBase<T>({
|
|||||||
// If option is passed as a plain value (value property from SelectableValue property)
|
// If option is passed as a plain value (value property from SelectableValue property)
|
||||||
// we are selecting the corresponding value from the options
|
// we are selecting the corresponding value from the options
|
||||||
if (isMulti && value && Array.isArray(value) && !loadOptions) {
|
if (isMulti && value && Array.isArray(value) && !loadOptions) {
|
||||||
|
selectedValue = value.map((v) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
selectedValue = value.map((v) => findSelectedValue(v.value ?? v, options));
|
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) {
|
} else if (loadOptions) {
|
||||||
const hasValue = defaultValue || value;
|
const hasValue = defaultValue || value;
|
||||||
selectedValue = hasValue ? [hasValue] : [];
|
selectedValue = hasValue ? [hasValue] : [];
|
||||||
|
@ -362,5 +362,11 @@ var (
|
|||||||
State: FeatureStateAlpha,
|
State: FeatureStateAlpha,
|
||||||
FrontendOnly: true,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -262,4 +262,8 @@ const (
|
|||||||
// FlagDrawerDataSourcePicker
|
// FlagDrawerDataSourcePicker
|
||||||
// Changes the user experience for data source selection to a drawer.
|
// Changes the user experience for data source selection to a drawer.
|
||||||
FlagDrawerDataSourcePicker = "drawerDataSourcePicker"
|
FlagDrawerDataSourcePicker = "drawerDataSourcePicker"
|
||||||
|
|
||||||
|
// FlagTraceqlSearch
|
||||||
|
// Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries
|
||||||
|
FlagTraceqlSearch = "traceqlSearch"
|
||||||
)
|
)
|
||||||
|
@ -9,6 +9,12 @@
|
|||||||
|
|
||||||
package dataquery
|
package dataquery
|
||||||
|
|
||||||
|
// 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"
|
||||||
@ -16,16 +22,51 @@ const (
|
|||||||
TempoQueryTypeSearch TempoQueryType = "search"
|
TempoQueryTypeSearch TempoQueryType = "search"
|
||||||
TempoQueryTypeServiceMap TempoQueryType = "serviceMap"
|
TempoQueryTypeServiceMap TempoQueryType = "serviceMap"
|
||||||
TempoQueryTypeTraceql TempoQueryType = "traceql"
|
TempoQueryTypeTraceql TempoQueryType = "traceql"
|
||||||
|
TempoQueryTypeTraceqlSearch TempoQueryType = "traceqlSearch"
|
||||||
TempoQueryTypeUpload TempoQueryType = "upload"
|
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.
|
// 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 mixed data sources the selected datasource is on the query level.
|
||||||
// For non mixed scenarios this is undefined.
|
// For non mixed scenarios this is undefined.
|
||||||
// TODO find a better way to do this ^ that's friendly to schema
|
// TODO find a better way to do this ^ that's friendly to schema
|
||||||
// TODO this shouldn't be unknown but DataSourceRef | null
|
// TODO this shouldn't be unknown but DataSourceRef | null
|
||||||
Datasource *interface{} `json:"datasource,omitempty"`
|
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 true if query is disabled (ie should not be returned to the dashboard)
|
||||||
Hide *bool `json:"hide,omitempty"`
|
Hide *bool `json:"hide,omitempty"`
|
||||||
@ -65,5 +106,35 @@ type TempoDataQuery struct {
|
|||||||
SpanName *string `json:"spanName,omitempty"`
|
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
|
// TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
||||||
type TempoQueryType string
|
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
|
||||||
|
@ -61,7 +61,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
|||||||
queryRes := backend.DataResponse{}
|
queryRes := backend.DataResponse{}
|
||||||
refID := req.Queries[0].RefID
|
refID := req.Queries[0].RefID
|
||||||
|
|
||||||
model := &dataquery.TempoDataQuery{}
|
model := &dataquery.TempoQuery{}
|
||||||
err := json.Unmarshal(req.Queries[0].JSON, model)
|
err := json.Unmarshal(req.Queries[0].JSON, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
import { LokiQueryField } from '../../loki/components/LokiQueryField';
|
import { LokiQueryField } from '../../loki/components/LokiQueryField';
|
||||||
import { LokiDatasource } from '../../loki/datasource';
|
import { LokiDatasource } from '../../loki/datasource';
|
||||||
import { LokiQuery } from '../../loki/types';
|
import { LokiQuery } from '../../loki/types';
|
||||||
|
import TraceQLSearch from '../SearchTraceQLEditor/TraceQLSearch';
|
||||||
import { TempoQueryType } from '../dataquery.gen';
|
import { TempoQueryType } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
import { QueryEditor } from '../traceql/QueryEditor';
|
import { QueryEditor } from '../traceql/QueryEditor';
|
||||||
@ -28,7 +29,7 @@ import { getDS } from './utils';
|
|||||||
|
|
||||||
interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {}
|
interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {}
|
||||||
|
|
||||||
const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceql';
|
const DEFAULT_QUERY_TYPE: TempoQueryType = config.featureToggles.traceqlSearch ? 'traceqlSearch' : 'traceql';
|
||||||
|
|
||||||
class TempoQueryFieldComponent extends React.PureComponent<Props> {
|
class TempoQueryFieldComponent extends React.PureComponent<Props> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
@ -83,7 +84,11 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
|
|||||||
{ value: 'serviceMap', label: 'Service Graph' },
|
{ 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' });
|
queryTypeOptions.unshift({ value: 'nativeSearch', label: 'Search' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +146,14 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
|
|||||||
onRunQuery={this.props.onRunQuery}
|
onRunQuery={this.props.onRunQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{query.queryType === 'traceqlSearch' && (
|
||||||
|
<TraceQLSearch
|
||||||
|
datasource={this.props.datasource}
|
||||||
|
query={query}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={this.props.onBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{query.queryType === 'upload' && (
|
{query.queryType === 'upload' && (
|
||||||
<div className={css({ padding: this.props.theme.spacing(2) })}>
|
<div className={css({ padding: this.props.theme.spacing(2) })}>
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Select, HorizontalGroup, Input } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { TraceqlFilter } from '../dataquery.gen';
|
||||||
|
|
||||||
|
import { operatorSelectableValue } from './utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filter: TraceqlFilter;
|
||||||
|
updateFilter: (f: TraceqlFilter) => void;
|
||||||
|
isTagsLoading?: boolean;
|
||||||
|
operators: string[];
|
||||||
|
}
|
||||||
|
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
||||||
|
return (
|
||||||
|
<HorizontalGroup spacing={'none'}>
|
||||||
|
<Select
|
||||||
|
inputId={`${filter.id}-operator`}
|
||||||
|
options={operators.map(operatorSelectableValue)}
|
||||||
|
value={filter.operator}
|
||||||
|
onChange={(v) => {
|
||||||
|
updateFilter({ ...filter, operator: v?.value });
|
||||||
|
}}
|
||||||
|
isClearable={false}
|
||||||
|
aria-label={`select ${filter.id} operator`}
|
||||||
|
allowCustomValue={true}
|
||||||
|
width={8}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={filter.value}
|
||||||
|
onChange={(v) => {
|
||||||
|
updateFilter({ ...filter, value: v.currentTarget.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g. 100ms, 1.2s"
|
||||||
|
aria-label={`select ${filter.id} value`}
|
||||||
|
width={18}
|
||||||
|
/>
|
||||||
|
</HorizontalGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DurationInput;
|
@ -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<Props> = ({ label, tooltip, children }) => {
|
||||||
|
return (
|
||||||
|
<InlineFieldRow>
|
||||||
|
<InlineField label={label} labelWidth={16} grow tooltip={tooltip}>
|
||||||
|
{children}
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchField;
|
@ -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<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SearchField
|
||||||
|
datasource={{} as TempoDatasource}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
filter={filter}
|
||||||
|
setError={function (error: FetchError): void {
|
||||||
|
throw error;
|
||||||
|
}}
|
||||||
|
tags={tags || []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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<Array<SelectableValue<string>>>([]);
|
||||||
|
// 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 (
|
||||||
|
<HorizontalGroup spacing={'none'}>
|
||||||
|
{filter.type === 'dynamic' && (
|
||||||
|
<Select
|
||||||
|
inputId={`${filter.id}-tag`}
|
||||||
|
isLoading={isTagsLoading}
|
||||||
|
options={tags.map((t) => ({ label: t, value: t }))}
|
||||||
|
onOpenMenu={() => tags}
|
||||||
|
value={filter.tag}
|
||||||
|
onChange={(v) => {
|
||||||
|
updateFilter({ ...filter, tag: v?.value });
|
||||||
|
}}
|
||||||
|
placeholder="Select tag"
|
||||||
|
isClearable
|
||||||
|
aria-label={`select ${filter.id} tag`}
|
||||||
|
allowCustomValue={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
inputId={`${filter.id}-operator`}
|
||||||
|
options={(operators || allOperators).map(operatorSelectableValue)}
|
||||||
|
value={filter.operator}
|
||||||
|
onChange={(v) => {
|
||||||
|
updateFilter({ ...filter, operator: v?.value });
|
||||||
|
}}
|
||||||
|
isClearable={false}
|
||||||
|
aria-label={`select ${filter.id} operator`}
|
||||||
|
allowCustomValue={true}
|
||||||
|
width={8}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
inputId={`${filter.id}-value`}
|
||||||
|
isLoading={isLoadingValues}
|
||||||
|
options={options}
|
||||||
|
onOpenMenu={() => {
|
||||||
|
if (filter.tag) {
|
||||||
|
loadOptions(filter.tag);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={filter.value}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type });
|
||||||
|
} else {
|
||||||
|
updateFilter({ ...filter, value: val?.value, valueType: val?.type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Select value"
|
||||||
|
isClearable={false}
|
||||||
|
aria-label={`select ${filter.id} value`}
|
||||||
|
allowCustomValue={true}
|
||||||
|
isMulti
|
||||||
|
/>
|
||||||
|
{filter.type === 'dynamic' && (
|
||||||
|
<AccessoryButton
|
||||||
|
variant={'secondary'}
|
||||||
|
icon={'times'}
|
||||||
|
onClick={() => deleteFilter?.(filter)}
|
||||||
|
tooltip={'Remove tag'}
|
||||||
|
aria-label={`remove tag with ID ${filter.id}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HorizontalGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchField;
|
@ -0,0 +1,58 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { AccessoryButton } from '@grafana/experimental';
|
||||||
|
import { FetchError } from '@grafana/runtime';
|
||||||
|
import { HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { TraceqlFilter } from '../dataquery.gen';
|
||||||
|
import { TempoDatasource } from '../datasource';
|
||||||
|
|
||||||
|
import SearchField from './SearchField';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
updateFilter: (f: TraceqlFilter) => void;
|
||||||
|
deleteFilter: (f: TraceqlFilter) => void;
|
||||||
|
filters: TraceqlFilter[];
|
||||||
|
datasource: TempoDatasource;
|
||||||
|
setError: (error: FetchError) => void;
|
||||||
|
tags: string[];
|
||||||
|
isTagsLoading: boolean;
|
||||||
|
}
|
||||||
|
const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => {
|
||||||
|
const generateId = () => uuidv4().slice(0, 8);
|
||||||
|
const handleOnAdd = useCallback(
|
||||||
|
() => updateFilter({ id: generateId(), type: 'dynamic', operator: '=' }),
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filters?.find((f) => f.type === 'dynamic')) {
|
||||||
|
handleOnAdd();
|
||||||
|
}
|
||||||
|
}, [filters, handleOnAdd]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalGroup spacing={'md'} align={'flex-start'}>
|
||||||
|
<VerticalGroup spacing={'xs'}>
|
||||||
|
{filters
|
||||||
|
?.filter((f) => f.type === 'dynamic')
|
||||||
|
.map((f) => (
|
||||||
|
<SearchField
|
||||||
|
filter={f}
|
||||||
|
key={f.id}
|
||||||
|
datasource={datasource}
|
||||||
|
setError={setError}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
tags={tags}
|
||||||
|
isTagsLoading={isTagsLoading}
|
||||||
|
deleteFilter={deleteFilter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VerticalGroup>
|
||||||
|
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
|
||||||
|
</HorizontalGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagsInput;
|
@ -0,0 +1,131 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { TempoDatasource } from '../datasource';
|
||||||
|
import { TempoQuery } from '../types';
|
||||||
|
|
||||||
|
import TraceQLSearch from './TraceQLSearch';
|
||||||
|
|
||||||
|
const getOptionsV2 = jest.fn().mockImplementation(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
value: 'customer',
|
||||||
|
label: 'customer',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'driver',
|
||||||
|
label: 'driver',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTags = jest.fn().mockImplementation(() => {
|
||||||
|
return ['foo', 'bar'];
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../language_provider', () => {
|
||||||
|
return jest.fn().mockImplementation(() => {
|
||||||
|
return { getOptionsV2, getTags };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TraceQLSearch', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
let query: TempoQuery = {
|
||||||
|
refId: 'A',
|
||||||
|
queryType: 'traceqlSearch',
|
||||||
|
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
|
||||||
|
query: '',
|
||||||
|
filters: [{ id: 'min-duration', operator: '>', type: 'static', valueType: 'duration', tag: 'duration' }],
|
||||||
|
};
|
||||||
|
const onChange = (q: TempoQuery) => {
|
||||||
|
query = q;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 update operator when new value is selected in operator input', async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`);
|
||||||
|
expect(minDurationOperator).not.toBeNull();
|
||||||
|
expect(minDurationOperator).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('>')).toBeInTheDocument();
|
||||||
|
|
||||||
|
if (minDurationOperator) {
|
||||||
|
await user.click(minDurationOperator);
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
const regexOp = await screen.findByText('>=');
|
||||||
|
await user.click(regexOp);
|
||||||
|
const minDurationFilter = query.filters.find((f) => f.id === 'min-duration');
|
||||||
|
expect(minDurationFilter).not.toBeNull();
|
||||||
|
expect(minDurationFilter?.operator).toBe('>=');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add new filter when new value is selected in the service name section', async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
|
||||||
|
);
|
||||||
|
const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`);
|
||||||
|
expect(serviceNameValue).not.toBeNull();
|
||||||
|
expect(serviceNameValue).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(query.filters.find((f) => f.id === 'service-name')).not.toBeDefined();
|
||||||
|
|
||||||
|
if (serviceNameValue) {
|
||||||
|
await user.click(serviceNameValue);
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
const customerValue = await screen.findByText('customer');
|
||||||
|
await user.click(customerValue);
|
||||||
|
const nameFilter = query.filters.find((f) => f.id === 'service-name');
|
||||||
|
expect(nameFilter).not.toBeNull();
|
||||||
|
expect(nameFilter?.operator).toBe('=');
|
||||||
|
expect(nameFilter?.value).toStrictEqual(['customer']);
|
||||||
|
expect(nameFilter?.tag).toBe('.service.name');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,190 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { EditorRow } from '@grafana/experimental';
|
||||||
|
import { FetchError } from '@grafana/runtime';
|
||||||
|
import { Alert, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { createErrorNotification } from '../../../../core/copy/appNotification';
|
||||||
|
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||||
|
import { dispatch } from '../../../../store/store';
|
||||||
|
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
|
||||||
|
import { TraceqlFilter } from '../dataquery.gen';
|
||||||
|
import { TempoDatasource } from '../datasource';
|
||||||
|
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
|
||||||
|
import { CompletionProvider } from '../traceql/autocomplete';
|
||||||
|
import { traceqlGrammar } from '../traceql/traceql';
|
||||||
|
import { TempoQuery } from '../types';
|
||||||
|
|
||||||
|
import DurationInput from './DurationInput';
|
||||||
|
import InlineSearchField from './InlineSearchField';
|
||||||
|
import SearchField from './SearchField';
|
||||||
|
import TagsInput from './TagsInput';
|
||||||
|
import { generateQueryFromFilters, replaceAt } from './utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
datasource: TempoDatasource;
|
||||||
|
query: TempoQuery;
|
||||||
|
onChange: (value: TempoQuery) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [error, setError] = useState<Error | FetchError | null>(null);
|
||||||
|
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [isTagsLoading, setIsTagsLoading] = useState(true);
|
||||||
|
const [traceQlQuery, setTraceQlQuery] = useState<string>('');
|
||||||
|
|
||||||
|
const updateFilter = (s: TraceqlFilter) => {
|
||||||
|
const copy = { ...query };
|
||||||
|
copy.filters ||= [];
|
||||||
|
const indexOfFilter = copy.filters.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.filters = replaceAt(copy.filters, indexOfFilter, s);
|
||||||
|
} else {
|
||||||
|
copy.filters.push(s);
|
||||||
|
}
|
||||||
|
onChange(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFilter = (s: TraceqlFilter) => {
|
||||||
|
onChange({ ...query, filters: query.filters.filter((f) => f.id !== s.id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTraceQlQuery(generateQueryFromFilters(query.filters || []));
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const findFilter = (id: string) => query.filters?.find((f) => f.id === id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTags = async () => {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
const tagsWithDot = tags.sort().map((t) => `.${t}`);
|
||||||
|
setTags(tagsWithDot);
|
||||||
|
setIsTagsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
dispatch(notifyApp(createErrorNotification('Error', error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTags();
|
||||||
|
}, [datasource]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div>
|
||||||
|
<InlineSearchField label={'Service Name'}>
|
||||||
|
<SearchField
|
||||||
|
filter={
|
||||||
|
findFilter('service-name') || {
|
||||||
|
id: 'service-name',
|
||||||
|
type: 'static',
|
||||||
|
tag: '.service.name',
|
||||||
|
operator: '=',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
datasource={datasource}
|
||||||
|
setError={setError}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
tags={[]}
|
||||||
|
operators={['=', '!=', '=~']}
|
||||||
|
/>
|
||||||
|
</InlineSearchField>
|
||||||
|
<InlineSearchField label={'Span Name'}>
|
||||||
|
<SearchField
|
||||||
|
filter={findFilter('span-name') || { id: 'span-name', type: 'static', tag: 'name', operator: '=' }}
|
||||||
|
datasource={datasource}
|
||||||
|
setError={setError}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
tags={[]}
|
||||||
|
operators={['=', '!=', '=~']}
|
||||||
|
/>
|
||||||
|
</InlineSearchField>
|
||||||
|
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">
|
||||||
|
<HorizontalGroup spacing={'sm'}>
|
||||||
|
<DurationInput
|
||||||
|
filter={
|
||||||
|
findFilter('min-duration') || {
|
||||||
|
id: 'min-duration',
|
||||||
|
type: 'static',
|
||||||
|
tag: 'duration',
|
||||||
|
operator: '>',
|
||||||
|
valueType: 'duration',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operators={['>', '>=']}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
/>
|
||||||
|
<DurationInput
|
||||||
|
filter={
|
||||||
|
findFilter('max-duration') || {
|
||||||
|
id: 'max-duration',
|
||||||
|
type: 'static',
|
||||||
|
tag: 'duration',
|
||||||
|
operator: '<',
|
||||||
|
valueType: 'duration',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operators={['<', '<=']}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
/>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</InlineSearchField>
|
||||||
|
<InlineSearchField label={'Tags'}>
|
||||||
|
<TagsInput
|
||||||
|
filters={query.filters}
|
||||||
|
datasource={datasource}
|
||||||
|
setError={setError}
|
||||||
|
updateFilter={updateFilter}
|
||||||
|
deleteFilter={deleteFilter}
|
||||||
|
tags={[...CompletionProvider.intrinsics, ...tags]}
|
||||||
|
isTagsLoading={isTagsLoading}
|
||||||
|
/>
|
||||||
|
</InlineSearchField>
|
||||||
|
</div>
|
||||||
|
<EditorRow>
|
||||||
|
<RawQuery query={traceQlQuery} lang={{ grammar: traceqlGrammar, name: 'traceql' }} />
|
||||||
|
</EditorRow>
|
||||||
|
<TempoQueryBuilderOptions onChange={onChange} query={query} />
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<Alert title="Unable to connect to Tempo search" severity="info" className={styles.alert}>
|
||||||
|
Please ensure that Tempo is configured with search enabled. If you would like to hide this tab, you can
|
||||||
|
configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TraceQLSearch;
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
alert: css`
|
||||||
|
max-width: 75ch;
|
||||||
|
margin-top: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
|
container: css`
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-direction: column;
|
||||||
|
`,
|
||||||
|
});
|
@ -0,0 +1,57 @@
|
|||||||
|
import { generateQueryFromFilters } from './utils';
|
||||||
|
|
||||||
|
describe('generateQueryFromFilters generates the correct query for', () => {
|
||||||
|
it('an empty array', () => {
|
||||||
|
expect(generateQueryFromFilters([])).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a field without value', () => {
|
||||||
|
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', operator: '=' }])).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a field with value but without tag', () => {
|
||||||
|
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', value: 'foovalue', operator: '=' }])).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a field with value and tag but without operator', () => {
|
||||||
|
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue' }])).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a field with tag, operator and tag', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }])
|
||||||
|
).toBe('{footag="foovalue"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a field with valueType as integer', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' },
|
||||||
|
])
|
||||||
|
).toBe('{footag>1234}');
|
||||||
|
});
|
||||||
|
it('two fields with everything filled in', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
|
{ id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
|
||||||
|
])
|
||||||
|
).toBe('{footag>=1234 && bartag="barvalue"}');
|
||||||
|
});
|
||||||
|
it('two fields but one is missing a value', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
|
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
|
||||||
|
])
|
||||||
|
).toBe('{footag>=1234}');
|
||||||
|
});
|
||||||
|
it('two fields but one is missing a value and the other a tag', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{ id: 'foo', type: 'static', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
|
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
|
||||||
|
])
|
||||||
|
).toBe('{}');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,57 @@
|
|||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
|
import { TraceqlFilter } from '../dataquery.gen';
|
||||||
|
|
||||||
|
export const generateQueryFromFilters = (filters: TraceqlFilter[]) => {
|
||||||
|
return `{${filters
|
||||||
|
.filter((f) => f.tag && f.operator && f.value?.length)
|
||||||
|
.map((f) => `${f.tag}${f.operator}${valueHelper(f)}`)
|
||||||
|
.join(' && ')}}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueHelper = (f: TraceqlFilter) => {
|
||||||
|
if (Array.isArray(f.value) && f.value.length > 1) {
|
||||||
|
return `"${f.value.join('|')}"`;
|
||||||
|
}
|
||||||
|
if (!f.valueType || f.valueType === 'string') {
|
||||||
|
return `"${f.value}"`;
|
||||||
|
}
|
||||||
|
return f.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function replaceAt<T>(array: T[], index: number, value: T) {
|
||||||
|
const ret = array.slice(0);
|
||||||
|
ret[index] = value;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operatorSelectableValue = (op: string) => {
|
||||||
|
const result: SelectableValue = { label: op, value: op };
|
||||||
|
switch (op) {
|
||||||
|
case '=':
|
||||||
|
result.description = 'Equals';
|
||||||
|
break;
|
||||||
|
case '!=':
|
||||||
|
result.description = 'Not equals';
|
||||||
|
break;
|
||||||
|
case '>':
|
||||||
|
result.description = 'Greater';
|
||||||
|
break;
|
||||||
|
case '>=':
|
||||||
|
result.description = 'Greater or Equal';
|
||||||
|
break;
|
||||||
|
case '<':
|
||||||
|
result.description = 'Less';
|
||||||
|
break;
|
||||||
|
case '<=':
|
||||||
|
result.description = 'Less or Equal';
|
||||||
|
break;
|
||||||
|
case '=~':
|
||||||
|
result.description = 'Matches regex';
|
||||||
|
break;
|
||||||
|
case '!~':
|
||||||
|
result.description = 'Does not match regex';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
@ -30,8 +30,7 @@ composableKinds: DataQuery: {
|
|||||||
{
|
{
|
||||||
schemas: [
|
schemas: [
|
||||||
{
|
{
|
||||||
common.DataQuery
|
#TempoQuery: common.DataQuery & {
|
||||||
|
|
||||||
// TraceQL query or trace ID
|
// TraceQL query or trace ID
|
||||||
query: string
|
query: string
|
||||||
// Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
|
// Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
|
||||||
@ -48,9 +47,28 @@ composableKinds: DataQuery: {
|
|||||||
serviceMapQuery?: string
|
serviceMapQuery?: string
|
||||||
// Defines the maximum number of traces that are returned from Tempo
|
// Defines the maximum number of traces that are returned from Tempo
|
||||||
limit?: int64
|
limit?: int64
|
||||||
|
filters: [...#TraceqlFilter]
|
||||||
|
} @cuetsy(kind="interface") @grafana(TSVeneer="type")
|
||||||
|
|
||||||
// search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
// search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
||||||
#TempoQueryType: "traceql" | "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
|
||||||
|
#TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type")
|
||||||
|
#TraceqlFilter: {
|
||||||
|
// Uniquely identify the filter, will not be used in the query generation
|
||||||
|
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
|
||||||
|
tag?: string
|
||||||
|
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||||
|
operator?: string
|
||||||
|
// The value for the search filter
|
||||||
|
value?: string | [...string]
|
||||||
|
// 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
|
||||||
|
} @cuetsy(kind="interface")
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -12,12 +12,8 @@ import * as common from '@grafana/schema';
|
|||||||
|
|
||||||
export const DataQueryModelVersion = Object.freeze([0, 0]);
|
export const DataQueryModelVersion = Object.freeze([0, 0]);
|
||||||
|
|
||||||
/**
|
export interface TempoQuery extends common.DataQuery {
|
||||||
* search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
filters: Array<TraceqlFilter>;
|
||||||
*/
|
|
||||||
export type TempoQueryType = ('traceql' | 'search' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear');
|
|
||||||
|
|
||||||
export interface Tempo extends common.DataQuery {
|
|
||||||
/**
|
/**
|
||||||
* Defines the maximum number of traces that are returned from Tempo
|
* Defines the maximum number of traces that are returned from Tempo
|
||||||
*/
|
*/
|
||||||
@ -51,3 +47,46 @@ export interface Tempo extends common.DataQuery {
|
|||||||
*/
|
*/
|
||||||
spanName?: string;
|
spanName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const defaultTempoQuery: Partial<TempoQuery> = {
|
||||||
|
filters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
||||||
|
*/
|
||||||
|
export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* static fields are pre-set in the UI, dynamic fields are added by the user
|
||||||
|
*/
|
||||||
|
export type TraceqlSearchFilterType = ('static' | 'dynamic');
|
||||||
|
|
||||||
|
export interface TraceqlFilter {
|
||||||
|
/**
|
||||||
|
* Uniquely identify the filter, will not be used in the query generation
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||||
|
*/
|
||||||
|
operator?: string;
|
||||||
|
/**
|
||||||
|
* The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
value?: (string | Array<string>);
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tempo {}
|
||||||
|
@ -76,6 +76,7 @@ describe('Tempo data source', () => {
|
|||||||
search: '$interpolationVar',
|
search: '$interpolationVar',
|
||||||
minDuration: '$interpolationVar',
|
minDuration: '$interpolationVar',
|
||||||
maxDuration: '$interpolationVar',
|
maxDuration: '$interpolationVar',
|
||||||
|
filters: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,6 +244,7 @@ describe('Tempo data source', () => {
|
|||||||
minDuration: '$interpolationVar',
|
minDuration: '$interpolationVar',
|
||||||
maxDuration: '$interpolationVar',
|
maxDuration: '$interpolationVar',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
filters: [],
|
||||||
};
|
};
|
||||||
const builtQuery = ds.buildSearchQuery(tempoQuery);
|
const builtQuery = ds.buildSearchQuery(tempoQuery);
|
||||||
expect(builtQuery).toStrictEqual({
|
expect(builtQuery).toStrictEqual({
|
||||||
@ -260,6 +262,7 @@ describe('Tempo data source', () => {
|
|||||||
refId: 'A',
|
refId: 'A',
|
||||||
query: '',
|
query: '',
|
||||||
search: '',
|
search: '',
|
||||||
|
filters: [],
|
||||||
};
|
};
|
||||||
const builtQuery = ds.buildSearchQuery(tempoQuery);
|
const builtQuery = ds.buildSearchQuery(tempoQuery);
|
||||||
expect(builtQuery).toStrictEqual({
|
expect(builtQuery).toStrictEqual({
|
||||||
@ -275,6 +278,7 @@ describe('Tempo data source', () => {
|
|||||||
refId: 'A',
|
refId: 'A',
|
||||||
query: '',
|
query: '',
|
||||||
search: '',
|
search: '',
|
||||||
|
filters: [],
|
||||||
};
|
};
|
||||||
const timeRange = { startTime: 0, endTime: 1000 };
|
const timeRange = { startTime: 0, endTime: 1000 };
|
||||||
const builtQuery = ds.buildSearchQuery(tempoQuery, timeRange);
|
const builtQuery = ds.buildSearchQuery(tempoQuery, timeRange);
|
||||||
@ -289,6 +293,7 @@ describe('Tempo data source', () => {
|
|||||||
it('formats native search query history correctly', () => {
|
it('formats native search query history correctly', () => {
|
||||||
const ds = new TempoDatasource(defaultSettings);
|
const ds = new TempoDatasource(defaultSettings);
|
||||||
const tempoQuery: TempoQuery = {
|
const tempoQuery: TempoQuery = {
|
||||||
|
filters: [],
|
||||||
queryType: 'nativeSearch',
|
queryType: 'nativeSearch',
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
query: '',
|
query: '',
|
||||||
|
@ -34,6 +34,7 @@ import { LokiOptions } from '../loki/types';
|
|||||||
import { PrometheusDatasource } from '../prometheus/datasource';
|
import { PrometheusDatasource } from '../prometheus/datasource';
|
||||||
import { PromQuery } from '../prometheus/types';
|
import { PromQuery } from '../prometheus/types';
|
||||||
|
|
||||||
|
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
|
||||||
import {
|
import {
|
||||||
failedMetric,
|
failedMetric,
|
||||||
histogramMetric,
|
histogramMetric,
|
||||||
@ -223,6 +224,36 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
|
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (targets.traceqlSearch?.length) {
|
||||||
|
try {
|
||||||
|
const queryValue = generateQueryFromFilters(targets.traceqlSearch[0].filters);
|
||||||
|
reportInteraction('grafana_traces_traceql_search_queried', {
|
||||||
|
datasourceType: 'tempo',
|
||||||
|
app: options.app ?? '',
|
||||||
|
grafana_version: config.buildInfo.version,
|
||||||
|
query: queryValue ?? '',
|
||||||
|
});
|
||||||
|
subQueries.push(
|
||||||
|
this._request('/api/search', {
|
||||||
|
q: queryValue,
|
||||||
|
limit: options.targets[0].limit,
|
||||||
|
start: options.range.from.unix(),
|
||||||
|
end: options.range.to.unix(),
|
||||||
|
}).pipe(
|
||||||
|
map((response) => {
|
||||||
|
return {
|
||||||
|
data: createTableFrameFromTraceQlQuery(response.data.traces, this.instanceSettings),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
return of({ error: { message: error.data.message }, data: [] });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (targets.upload?.length) {
|
if (targets.upload?.length) {
|
||||||
if (this.uploadedJson) {
|
if (this.uploadedJson) {
|
||||||
|
@ -20,6 +20,7 @@ interface Props {
|
|||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
onRunQuery: () => void;
|
onRunQuery: () => void;
|
||||||
datasource: TempoDatasource;
|
datasource: TempoDatasource;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TraceQLEditor(props: Props) {
|
export function TraceQLEditor(props: Props) {
|
||||||
@ -35,6 +36,7 @@ export function TraceQLEditor(props: Props) {
|
|||||||
onBlur={onChange}
|
onBlur={onChange}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
containerStyles={styles.queryField}
|
containerStyles={styles.queryField}
|
||||||
|
readOnly={props.readOnly}
|
||||||
monacoOptions={{
|
monacoOptions={{
|
||||||
folding: false,
|
folding: false,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@ -52,9 +54,11 @@ export function TraceQLEditor(props: Props) {
|
|||||||
}}
|
}}
|
||||||
onBeforeEditorMount={ensureTraceQL}
|
onBeforeEditorMount={ensureTraceQL}
|
||||||
onEditorDidMount={(editor, monaco) => {
|
onEditorDidMount={(editor, monaco) => {
|
||||||
|
if (!props.readOnly) {
|
||||||
setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor));
|
setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor));
|
||||||
setupActions(editor, monaco, onRunQuery);
|
setupActions(editor, monaco, onRunQuery);
|
||||||
setupPlaceholder(editor, monaco, styles);
|
setupPlaceholder(editor, monaco, styles);
|
||||||
|
}
|
||||||
setupAutoSize(editor);
|
setupAutoSize(editor);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Grammar } from 'prismjs';
|
||||||
|
|
||||||
export const languageConfiguration = {
|
export const languageConfiguration = {
|
||||||
// the default separators except `@$`
|
// the default separators except `@$`
|
||||||
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
|
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
|
||||||
@ -20,7 +22,7 @@ export const languageConfiguration = {
|
|||||||
folding: {},
|
folding: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const operators = ['=', '!=', '>', '<', '>=', '<=', '=~', '!~'];
|
export const operators = ['=', '!=', '>', '<', '>=', '<=', '=~'];
|
||||||
|
|
||||||
const intrinsics = ['duration', 'name', 'status', 'parent'];
|
const intrinsics = ['duration', 'name', 'status', 'parent'];
|
||||||
|
|
||||||
@ -134,3 +136,34 @@ export const languageDefinition = {
|
|||||||
languageConfiguration,
|
languageConfiguration,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const traceqlGrammar: Grammar = {
|
||||||
|
comment: {
|
||||||
|
pattern: /#.*/,
|
||||||
|
},
|
||||||
|
'span-set': {
|
||||||
|
pattern: /\{[^}]*}/,
|
||||||
|
inside: {
|
||||||
|
filter: {
|
||||||
|
pattern: /([\w.\/-]+)?(\s*)(([!=+\-<>~]+)\s*("([^"\n&]+)?"?|([^"\n\s&|}]+))?)/g,
|
||||||
|
inside: {
|
||||||
|
comment: {
|
||||||
|
pattern: /#.*/,
|
||||||
|
},
|
||||||
|
'label-key': {
|
||||||
|
pattern: /[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/,
|
||||||
|
alias: 'attr-name',
|
||||||
|
},
|
||||||
|
'label-value': {
|
||||||
|
pattern: /("(?:\\.|[^\\"])*")|(\w+)/,
|
||||||
|
alias: 'attr-value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
punctuation: /[}{&|]/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
|
||||||
|
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|`, 'i'),
|
||||||
|
punctuation: /[{};()`,.]/,
|
||||||
|
};
|
||||||
|
@ -4,7 +4,7 @@ import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsS
|
|||||||
|
|
||||||
import { LokiQuery } from '../loki/types';
|
import { LokiQuery } from '../loki/types';
|
||||||
|
|
||||||
import { Tempo as TempoBase, TempoQueryType } from './dataquery.gen';
|
import { TempoQuery as TempoBase, TempoQueryType } from './dataquery.gen';
|
||||||
|
|
||||||
export interface SearchQueryParams {
|
export interface SearchQueryParams {
|
||||||
minDuration?: string;
|
minDuration?: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user