mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-14 22:20:49 +01:00
Compare commits
1 Commits
feat/ai-tr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375b3f8305 |
@@ -18,13 +18,11 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
index,
|
||||
version,
|
||||
panelType,
|
||||
signalSource = '',
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
panelType: PANEL_TYPES | null;
|
||||
signalSource: string;
|
||||
}): JSX.Element {
|
||||
const { setAggregationOptions } = useQueryBuilderV2Context();
|
||||
const {
|
||||
@@ -206,7 +204,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,7 +240,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -316,7 +316,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
|
||||
version="v4"
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
function ExplorerOrderBy({ query, onChange }: OrderByFilterProps): JSX.Element {
|
||||
const {
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
aggregationOptions,
|
||||
generateOptions,
|
||||
createOptions,
|
||||
handleChange,
|
||||
handleSearchKeys,
|
||||
} = useOrderByFilter({ query, onChange });
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const keysOptions = createOptions(data?.payload?.attributeKeys || []);
|
||||
|
||||
const customOptions = createOptions([
|
||||
{ key: 'timestamp', type: '', dataType: DataTypes.EMPTY },
|
||||
]);
|
||||
|
||||
const baseOptions = [
|
||||
...customOptions,
|
||||
...(query.aggregateOperator === StringOperators.NOOP
|
||||
? []
|
||||
: aggregationOptions),
|
||||
...keysOptions,
|
||||
];
|
||||
|
||||
return generateOptions(baseOptions);
|
||||
}, [
|
||||
aggregationOptions,
|
||||
createOptions,
|
||||
data?.payload?.attributeKeys,
|
||||
generateOptions,
|
||||
query.aggregateOperator,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
showSearch
|
||||
showArrow={false}
|
||||
value={selectedValue}
|
||||
labelInValue
|
||||
options={options}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ExplorerOrderBy);
|
||||
@@ -1,18 +1,5 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Total, not Partial, so a signal added to the generated enum has to be mapped here
|
||||
// before it compiles.
|
||||
const SIGNAL_TO_DATA_SOURCE: Record<
|
||||
TelemetrytypesSignalDTO,
|
||||
DataSource | undefined
|
||||
> = {
|
||||
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
|
||||
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
|
||||
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
|
||||
// The "unset" member: not a data source a query can be built against.
|
||||
[TelemetrytypesSignalDTO['']]: undefined,
|
||||
};
|
||||
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
|
||||
|
||||
export function signalsToDataSources(
|
||||
signals: readonly TelemetrytypesSignalDTO[],
|
||||
|
||||
@@ -5,5 +5,4 @@ export type GroupByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: BaseAutocompleteData[]) => void;
|
||||
disabled: boolean;
|
||||
signalSource?: string;
|
||||
};
|
||||
|
||||
@@ -6,39 +6,37 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
// ** Constants
|
||||
import { idDivider, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { idDivider } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
// ** Components
|
||||
// ** Helpers
|
||||
import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
DATA_SOURCE_TO_SIGNAL,
|
||||
QUERY_SOURCE_TO_FIELD_SOURCE,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import { getMetricNameForQuery } from '../utils';
|
||||
import OptionRenderer from '../OptionRenderer/OptionRenderer';
|
||||
import { GroupByFilterProps } from './GroupByFilter.interfaces';
|
||||
import { toAttributeKey } from './utils';
|
||||
|
||||
export const GroupByFilter = memo(function GroupByFilter({
|
||||
query,
|
||||
onChange,
|
||||
disabled,
|
||||
signalSource,
|
||||
}: GroupByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [optionsData, setOptionsData] = useState<
|
||||
SelectOption<string, ReactNode>[]
|
||||
>([]);
|
||||
const [localValues, setLocalValues] = useState<SelectOption<string, string>[]>(
|
||||
[],
|
||||
);
|
||||
@@ -46,74 +44,40 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
|
||||
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
if (signalSource === 'meter') {
|
||||
return 'meter' as DataSource;
|
||||
}
|
||||
return query.dataSource;
|
||||
}, [signalSource, query.dataSource]);
|
||||
|
||||
const { isFetching } = useGetAggregateKeys(
|
||||
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
|
||||
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
|
||||
metricName: getMetricNameForQuery(query),
|
||||
searchText: debouncedValue,
|
||||
},
|
||||
{
|
||||
queryKey: [debouncedValue, isFocused],
|
||||
enabled: !disabled && isFocused,
|
||||
onSuccess: (data) => {
|
||||
const keys = query.groupBy.reduce<string[]>((acc, item) => {
|
||||
acc.push(item.key);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const filteredOptions: BaseAutocompleteData[] =
|
||||
data?.payload?.attributeKeys?.filter(
|
||||
(attrKey) => !keys.includes(attrKey.key),
|
||||
) || [];
|
||||
|
||||
const options: SelectOption<string, ReactNode>[] =
|
||||
filteredOptions.map((item) => ({
|
||||
label: (
|
||||
<OptionRenderer
|
||||
key={item.key}
|
||||
label={item.key}
|
||||
value={item.key}
|
||||
dataType={item.dataType || ''}
|
||||
type={item.type || ''}
|
||||
/>
|
||||
),
|
||||
value: `${item.id}`,
|
||||
})) || [];
|
||||
|
||||
setOptionsData(options);
|
||||
},
|
||||
},
|
||||
query.builderQueryType,
|
||||
{ enabled: !disabled && isFocused, keepPreviousData: true },
|
||||
);
|
||||
|
||||
const getAttributeKeys = useCallback(async () => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, searchText, isFocused],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
searchText,
|
||||
}),
|
||||
);
|
||||
const attributeKeys = useMemo(
|
||||
() => (fieldKeys ?? []).map(toAttributeKey),
|
||||
[fieldKeys],
|
||||
);
|
||||
|
||||
return response.payload?.attributeKeys || [];
|
||||
}, [
|
||||
isFocused,
|
||||
query.aggregateAttribute?.key,
|
||||
query.aggregateOperator,
|
||||
dataSource,
|
||||
queryClient,
|
||||
searchText,
|
||||
]);
|
||||
const optionsData = useMemo((): SelectOption<string, ReactNode>[] => {
|
||||
const selectedKeys = new Set(query.groupBy.map((item) => item.key));
|
||||
|
||||
return attributeKeys
|
||||
.filter((item) => !selectedKeys.has(item.key))
|
||||
.map((item) => ({
|
||||
label: (
|
||||
<OptionRenderer
|
||||
key={item.key}
|
||||
label={item.key}
|
||||
value={item.key}
|
||||
dataType={item.dataType || ''}
|
||||
type={item.type || ''}
|
||||
/>
|
||||
),
|
||||
value: `${item.id}`,
|
||||
}));
|
||||
}, [attributeKeys, query.groupBy]);
|
||||
|
||||
const handleSearchKeys = (searchText: string): void => {
|
||||
setSearchText(searchText);
|
||||
@@ -129,15 +93,13 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
};
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (values: SelectOption<string, string>[]): Promise<void> => {
|
||||
const keys = await getAttributeKeys();
|
||||
|
||||
(values: SelectOption<string, string>[]): void => {
|
||||
const groupByValues: BaseAutocompleteData[] = values.map((item) => {
|
||||
const id = item.value;
|
||||
const currentValue = item.value.split(idDivider)[0];
|
||||
|
||||
if (id && id.includes(idDivider)) {
|
||||
const attribute = keys.find((item) => item.id === id);
|
||||
const attribute = attributeKeys.find((item) => item.id === id);
|
||||
const existAttribute = query.groupBy.find((item) => item.id === id);
|
||||
|
||||
if (attribute) {
|
||||
@@ -149,14 +111,14 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
}
|
||||
}
|
||||
|
||||
return chooseAutocompleteFromCustomValue(keys, currentValue);
|
||||
return chooseAutocompleteFromCustomValue(attributeKeys, currentValue);
|
||||
});
|
||||
|
||||
const result = uniqWith(groupByValues, isEqual);
|
||||
|
||||
onChange(result);
|
||||
},
|
||||
[getAttributeKeys, onChange, query.groupBy],
|
||||
[attributeKeys, onChange, query.groupBy],
|
||||
);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
|
||||
@@ -9,137 +9,140 @@ import { IBuilderQuery } from '../../../../../types/api/queryBuilder/queryBuilde
|
||||
import { DataSource } from '../../../../../types/common/queryBuilder';
|
||||
import { GroupByFilter } from '../GroupByFilter';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
|
||||
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
|
||||
|
||||
function setup(
|
||||
overrides?: Partial<React.ComponentProps<typeof GroupByFilter>>,
|
||||
): { onChange: jest.Mock } {
|
||||
type FieldKeyVariant = {
|
||||
name: string;
|
||||
fieldContext: string;
|
||||
fieldDataType?: string;
|
||||
};
|
||||
|
||||
const fieldKeysBody = (
|
||||
variants: FieldKeyVariant[],
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: variants.reduce<Record<string, FieldKeyVariant[]>>(
|
||||
(acc, variant) => ({
|
||||
...acc,
|
||||
[variant.name]: [...(acc[variant.name] ?? []), variant],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const groupByInput = (): HTMLInputElement =>
|
||||
screen.getByTestId('group-by').querySelector('input') as HTMLInputElement;
|
||||
|
||||
function setup(queryOverrides?: Partial<IBuilderQuery>): {
|
||||
onChange: jest.Mock;
|
||||
} {
|
||||
const onChange = jest.fn();
|
||||
const query = {
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateAttribute: { key: 'service.name' },
|
||||
aggregateAttribute: { key: 'signoz_calls_total' },
|
||||
aggregateOperator: 'COUNT',
|
||||
groupBy: [],
|
||||
...queryOverrides,
|
||||
} as unknown as IBuilderQuery;
|
||||
render(
|
||||
<GroupByFilter
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
disabled={false}
|
||||
signalSource="meter"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
render(<GroupByFilter query={query} onChange={onChange} disabled={false} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
// wait for dropdown and select the option instead of hitting Enter
|
||||
async function clickOption(label: string): Promise<void> {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const option = await screen.findByText(
|
||||
(content, el) =>
|
||||
content.trim() === label && !!el?.closest('.ant-select-item-option'),
|
||||
);
|
||||
await user.click(option);
|
||||
}
|
||||
|
||||
describe('GroupByFilter', () => {
|
||||
const dataSourceCalls: string[] = [];
|
||||
let callCount = 0;
|
||||
const requests: URLSearchParams[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
dataSourceCalls.length = 0;
|
||||
callCount = 0;
|
||||
requests.length = 0;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('uses meter datasource for suggestions and normalization', async () => {
|
||||
function useFieldKeys(variants: FieldKeyVariant[]): void {
|
||||
server.use(
|
||||
rest.get(attributeKeysURL, (req, res, ctx) => {
|
||||
const ds = req.url.searchParams.get('dataSource') || '';
|
||||
dataSourceCalls.push(ds);
|
||||
callCount += 1;
|
||||
const attributeKeys =
|
||||
callCount === 1
|
||||
? [
|
||||
{
|
||||
id: 'service.name--string--',
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { attributeKeys },
|
||||
}),
|
||||
);
|
||||
rest.get(fieldKeysURL, (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(ctx.status(200), ctx.json(fieldKeysBody(variants)));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { onChange } = setup();
|
||||
it('asks the fields API for meter keys and emits a typed custom key', async () => {
|
||||
useFieldKeys([]);
|
||||
const { onChange } = setup({ source: 'meter' });
|
||||
|
||||
const select = screen.getByTestId('group-by');
|
||||
fireEvent.focus(select);
|
||||
fireEvent.focus(screen.getByTestId('group-by'));
|
||||
|
||||
await waitFor(() => expect(dataSourceCalls.length).toBeGreaterThanOrEqual(1));
|
||||
expect(dataSourceCalls[0]).toBe('meter');
|
||||
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
|
||||
expect(requests[0].get('signal')).toBe('metrics');
|
||||
expect(requests[0].get('source')).toBe('meter');
|
||||
expect(requests[0].get('metricName')).toBe('signoz_calls_total');
|
||||
|
||||
const input = select.querySelector('input') as HTMLInputElement;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.type(input, 'custom.attr');
|
||||
// wait for dropdown and select the option instead of hitting Enter
|
||||
await screen.findByRole('listbox');
|
||||
const option = await screen.findByRole('option', { name: 'custom.attr' });
|
||||
await user.click(option);
|
||||
await user.type(groupByInput(), 'custom.attr');
|
||||
await clickOption('custom.attr');
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(dataSourceCalls[dataSourceCalls.length - 1]).toBe('meter');
|
||||
|
||||
const emitted = onChange.mock.calls[0][0][0];
|
||||
expect(emitted.key).toBe('custom.attr');
|
||||
expect(emitted.id).toBeTruthy();
|
||||
expect(emitted.id).not.toBe('----');
|
||||
});
|
||||
it('clicks suggested option and emits proper value', async () => {
|
||||
server.use(
|
||||
rest.get(attributeKeysURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: [
|
||||
{
|
||||
id: 'service.name--string--',
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
it('emits the picked suggestion with its context and data type', async () => {
|
||||
useFieldKeys([
|
||||
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
|
||||
]);
|
||||
const { onChange } = setup();
|
||||
const combo = screen.getByRole('combobox');
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(combo);
|
||||
await user.click(groupByInput());
|
||||
await clickOption('service.name');
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
const option = await screen.findByText(
|
||||
(content, el) =>
|
||||
content.trim() === 'service.name' &&
|
||||
!!el?.closest('.ant-select-item-option'),
|
||||
);
|
||||
await user.click(option);
|
||||
|
||||
// Expect payload to be resolved to the exact attribute returned by MSW
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
||||
const emitted = onChange.mock.calls[0][0][0];
|
||||
expect(emitted.key).toBe('service.name');
|
||||
expect(emitted.id).toBe('service.name--string--');
|
||||
// Expect payload to be resolved to the exact attribute returned by MSW
|
||||
expect(onChange.mock.calls[0][0][0]).toMatchObject({
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
dataType: 'string',
|
||||
id: 'service.name--string--resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers each context of a name as its own option', async () => {
|
||||
useFieldKeys([
|
||||
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
|
||||
{ name: 'service.name', fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
]);
|
||||
setup();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(groupByInput());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByText('service.name')
|
||||
.filter((el) => !!el.closest('.ant-select-item-option')),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
baseAutoCompleteIdKeysOrder,
|
||||
initialAutocompleteData,
|
||||
} from 'constants/queryBuilder';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
export const toAttributeKey = (
|
||||
key: TelemetryFieldKey,
|
||||
): BaseAutocompleteData => {
|
||||
const fields = {
|
||||
key: key.name,
|
||||
dataType: fieldDataTypeToDataType(key.fieldDataType),
|
||||
type: key.fieldContext ?? '',
|
||||
};
|
||||
|
||||
return {
|
||||
...initialAutocompleteData,
|
||||
...fields,
|
||||
id: createIdFromObjectFields(fields, baseAutoCompleteIdKeysOrder),
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
DATA_SOURCE_TO_SIGNAL,
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QUERY_SOURCE_TO_FIELD_SOURCE,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import { getMetricNameForQuery } from '../utils';
|
||||
import { OrderByFilterProps } from './OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from './useOrderByFilter';
|
||||
|
||||
@@ -27,13 +33,14 @@ export function OrderByFilter({
|
||||
handleSearchKeys,
|
||||
} = useOrderByFilter({ query, onChange, entityVersion });
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
|
||||
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
|
||||
metricName: getMetricNameForQuery(query),
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
query.builderQueryType,
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key || isRawQuery,
|
||||
keepPreviousData: true,
|
||||
@@ -47,9 +54,11 @@ export function OrderByFilter({
|
||||
);
|
||||
|
||||
const optionsData = useMemo(() => {
|
||||
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
|
||||
const groupByOptions = createOptions(query.groupBy);
|
||||
const aggregationOptionsFromParsed = createOptions(parsedAggregationOptions);
|
||||
const keyOptions = createOptions((fieldKeys ?? []).map((key) => key.name));
|
||||
const groupByOptions = createOptions(query.groupBy.map((item) => item.key));
|
||||
const aggregationOptionsFromParsed = createOptions(
|
||||
parsedAggregationOptions.map((item) => item.key),
|
||||
);
|
||||
|
||||
const options =
|
||||
query.aggregateOperator === MetricAggregateOperator.NOOP
|
||||
@@ -62,7 +71,7 @@ export function OrderByFilter({
|
||||
return generateOptions(options);
|
||||
}, [
|
||||
createOptions,
|
||||
data?.payload?.attributeKeys,
|
||||
fieldKeys,
|
||||
query.groupBy,
|
||||
query.aggregateOperator,
|
||||
parsedAggregationOptions,
|
||||
@@ -92,6 +101,7 @@ export function OrderByFilter({
|
||||
options={optionsData}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
data-testid="order-by"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { OrderByFilter } from '../OrderByFilter';
|
||||
|
||||
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
|
||||
|
||||
const fieldKeysBody = (names: string[]): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [name, [{ name, fieldContext: 'resource' }]]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const orderByInput = (): HTMLInputElement =>
|
||||
screen.getByTestId('order-by').querySelector('input') as HTMLInputElement;
|
||||
|
||||
function setup(query: Partial<IBuilderQuery>): { onChange: jest.Mock } {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<OrderByFilter
|
||||
query={
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: { key: '' },
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
...query,
|
||||
} as unknown as IBuilderQuery
|
||||
}
|
||||
onChange={onChange}
|
||||
isRawQuery
|
||||
isNewQueryV2
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
describe('OrderByFilter', () => {
|
||||
const requests: URLSearchParams[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
requests.length = 0;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('offers fields API keys in both directions for a raw query', async () => {
|
||||
server.use(
|
||||
rest.get(fieldKeysURL, (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(ctx.status(200), ctx.json(fieldKeysBody(['service.name'])));
|
||||
}),
|
||||
);
|
||||
const { onChange } = setup({});
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(orderByInput());
|
||||
|
||||
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
|
||||
expect(requests[0].get('signal')).toBe('logs');
|
||||
|
||||
await screen.findByText('service.name desc');
|
||||
await user.click(await screen.findByText('service.name asc'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ columnName: 'service.name', order: 'asc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import useDebounce from 'hooks/useDebounce';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
|
||||
@@ -25,7 +24,7 @@ export type UseOrderByFilterResult = {
|
||||
selectedValue: IOption[];
|
||||
aggregationOptions: IOption[];
|
||||
generateOptions: (options: IOption[]) => IOption[];
|
||||
createOptions: (data: BaseAutocompleteData[]) => IOption[];
|
||||
createOptions: (keys: string[]) => IOption[];
|
||||
handleChange: (values: IOption[]) => void;
|
||||
handleSearchKeys: (search: string) => void;
|
||||
};
|
||||
@@ -121,7 +120,7 @@ export const useOrderByFilter = ({
|
||||
};
|
||||
|
||||
const createOptions = useCallback(
|
||||
(data: BaseAutocompleteData[]): IOption[] => mapLabelValuePairs(data).flat(),
|
||||
(keys: string[]): IOption[] => mapLabelValuePairs(keys).flat(),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
OrderByPayload,
|
||||
@@ -34,11 +33,8 @@ export const transformToOrderByStringValues = (
|
||||
return prepareSelectedValue;
|
||||
};
|
||||
|
||||
export function mapLabelValuePairs(
|
||||
arr: BaseAutocompleteData[],
|
||||
): Array<IOption>[] {
|
||||
return arr.map((item) => {
|
||||
const value = item.key;
|
||||
export function mapLabelValuePairs(keys: string[]): Array<IOption>[] {
|
||||
return keys.map((value) => {
|
||||
return [
|
||||
{
|
||||
label: `${value} ${ORDERBY_FILTERS.ASC}`,
|
||||
|
||||
@@ -3,7 +3,12 @@ import { HAVING_FILTER_REGEXP } from 'constants/regExp';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import uniqWith from 'lodash-es/unionWith';
|
||||
import { parse } from 'papaparse';
|
||||
import { HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
HavingForm,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { ORDERBY_FILTERS } from './OrderByFilter/config';
|
||||
import {
|
||||
@@ -111,3 +116,17 @@ export const filterByOperatorConfig = (
|
||||
OPERATORS_CONFIG[key].includes(option.label),
|
||||
);
|
||||
};
|
||||
|
||||
export const getMetricNameForQuery = (
|
||||
query: IBuilderQuery,
|
||||
): string | undefined => {
|
||||
if (query.dataSource !== DataSource.METRICS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,8 +43,16 @@ export const getFieldKeysQueryOptions = (
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export type FieldKeysQueryOverrides = Partial<
|
||||
Omit<FieldKeysQueryOptions, 'queryKey' | 'queryFn' | 'select'>
|
||||
>;
|
||||
|
||||
export const useFieldKeysSuggestion = (
|
||||
fieldKeysConfig: FieldKeysConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
options?: FieldKeysQueryOverrides,
|
||||
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
|
||||
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));
|
||||
useQuery({
|
||||
...getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType),
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,61 @@
|
||||
import type { GetFieldsKeys200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest } from 'msw';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
autocompleteKeysResponse,
|
||||
autocompleteValuesResponse,
|
||||
} from './__story_mockdata__/queryBuilderAutocomplete';
|
||||
|
||||
/**
|
||||
* The v5 keys list, served from the same per-signal fixture as the v3 pair so
|
||||
* the group-by and order-by filters open on the product's keys. A context
|
||||
* prefix in `searchText` narrows to that context the way the backend does.
|
||||
*/
|
||||
const fieldKeysResponseForSignal = (
|
||||
signal: string | null,
|
||||
searchText: string,
|
||||
): GetFieldsKeys200 => {
|
||||
const context = Object.values(TelemetrytypesFieldContextDTO).find(
|
||||
(ctx) => ctx && searchText.toLowerCase().startsWith(`${ctx}.`),
|
||||
);
|
||||
const keys = (
|
||||
autocompleteKeysResponse(
|
||||
SIGNAL_TO_DATA_SOURCE[signal as TelemetrytypesSignalDTO] ?? DataSource.LOGS,
|
||||
context ? searchText.slice(context.length + 1) : searchText,
|
||||
).data.attributeKeys ?? []
|
||||
).map((attribute) => ({
|
||||
name: attribute.key,
|
||||
signal: signal as TelemetrytypesSignalDTO,
|
||||
fieldContext:
|
||||
attribute.type === 'resource'
|
||||
? TelemetrytypesFieldContextDTO.resource
|
||||
: TelemetrytypesFieldContextDTO.attribute,
|
||||
fieldDataType: (attribute.dataType === DataTypes.Int64 ||
|
||||
attribute.dataType === DataTypes.Float64
|
||||
? TelemetrytypesFieldDataTypeDTO.number
|
||||
: attribute.dataType) as TelemetrytypesFieldDataTypeDTO,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
keys
|
||||
.filter((key) => !context || key.fieldContext === context)
|
||||
.map((key) => [key.name, [key]]),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The query shapes `src/mocks-server/handlers.ts` answers from its own fixture:
|
||||
* the APM resource attribute filter, the one case its two autocomplete handlers
|
||||
@@ -37,6 +88,17 @@ const isSharedFixtureValuesQuery = (params: URLSearchParams): boolean => {
|
||||
* both. Returning nothing hands the request on to the next matching handler.
|
||||
*/
|
||||
export const queryBuilderHandlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
fieldKeysResponseForSignal(
|
||||
req.url.searchParams.get('signal'),
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
(req, res, ctx) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { Format } from 'constants/formats/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
BuilderQuerySource,
|
||||
DataSource,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
@@ -104,7 +105,7 @@ export type IBuilderQuery = {
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
|
||||
source?: 'meter' | '';
|
||||
source?: BuilderQuerySource;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
/** Heatmap only. */
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Format } from 'constants/formats/types';
|
||||
@@ -32,6 +35,31 @@ export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
// Total, not Partial, so a signal added to the generated enum has to be mapped here
|
||||
// before it compiles.
|
||||
export const SIGNAL_TO_DATA_SOURCE: Record<
|
||||
TelemetrytypesSignalDTO,
|
||||
DataSource | undefined
|
||||
> = {
|
||||
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
|
||||
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
|
||||
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
|
||||
// The "unset" member: not a data source a query can be built against.
|
||||
[TelemetrytypesSignalDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export type BuilderQuerySource = `${TelemetrytypesSourceDTO}`;
|
||||
|
||||
// `source` tells meter and metrics apart, since both share the metrics signal:
|
||||
// meter sends 'meter', the rest is ''.
|
||||
export const QUERY_SOURCE_TO_FIELD_SOURCE: Record<
|
||||
BuilderQuerySource,
|
||||
TelemetrytypesSourceDTO | undefined
|
||||
> = {
|
||||
meter: TelemetrytypesSourceDTO.meter,
|
||||
'': undefined,
|
||||
};
|
||||
|
||||
export enum StringOperators {
|
||||
NOOP = 'noop',
|
||||
COUNT = 'count',
|
||||
|
||||
Reference in New Issue
Block a user