mirror of https://github.com/grafana/grafana
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 functionpull/64267/head
parent
5db0d14606
commit
fd37ff29b5
@ -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; |
||||
}; |
||||
Loading…
Reference in new issue