mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-16 00:10:42 +01:00
Compare commits
63 Commits
feat/sqlco
...
feat/ai-o1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32e31c2c4 | ||
|
|
38ab168fb0 | ||
|
|
bb39150733 | ||
|
|
754853b4a7 | ||
|
|
7b6b0eac7e | ||
|
|
edd0bfe89e | ||
|
|
0aeffaf6f6 | ||
|
|
874191c63c | ||
|
|
444f517d83 | ||
|
|
50bcebd37b | ||
|
|
d4cbbcf3f7 | ||
|
|
9130e14bf6 | ||
|
|
75eed0778d | ||
|
|
6862387501 | ||
|
|
035bada1a9 | ||
|
|
1555bc92fe | ||
|
|
0eb92cb072 | ||
|
|
fe7d4ea878 | ||
|
|
35ee51326c | ||
|
|
6c858f9e8b | ||
|
|
211983d8aa | ||
|
|
475705c6f5 | ||
|
|
46eae38c66 | ||
|
|
99e865387e | ||
|
|
946e2b18f3 | ||
|
|
f76fb33150 | ||
|
|
6c1c27c85c | ||
|
|
5e808a3ac1 | ||
|
|
844b1defd4 | ||
|
|
c2e7bc6552 | ||
|
|
e89b5648fb | ||
|
|
7112d5aa01 | ||
|
|
04d101a301 | ||
|
|
ae07c39b0e | ||
|
|
417d9a2fa4 | ||
|
|
9b0f3efd65 | ||
|
|
a0941f6f6a | ||
|
|
72185c93e4 | ||
|
|
65aaa45e0f | ||
|
|
e6e48ee7f0 | ||
|
|
3eb990f097 | ||
|
|
2c9d5a46d6 | ||
|
|
dc5485b312 | ||
|
|
a88cc79ef9 | ||
|
|
640d809597 | ||
|
|
6a9350fca3 | ||
|
|
e424082835 | ||
|
|
6dea6d67f7 | ||
|
|
2afed07b5b | ||
|
|
e45fa44729 | ||
|
|
eeb140c8b6 | ||
|
|
8cf36aec94 | ||
|
|
cec074aaf5 | ||
|
|
1b4b0f51f4 | ||
|
|
6e1edf6e0b | ||
|
|
d892c95af8 | ||
|
|
08005a0660 | ||
|
|
d3dad98e95 | ||
|
|
1c6fc53c46 | ||
|
|
1c0dc018e0 | ||
|
|
bb2511ce53 | ||
|
|
33d22c8b59 | ||
|
|
c1b9de0c8a |
14
frontend/src/api/querySuggestions/getFieldKeySuggestions.ts
Normal file
14
frontend/src/api/querySuggestions/getFieldKeySuggestions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observability';
|
||||
import { getFieldsKeys } from 'api/generated/services/fields';
|
||||
import type { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { FieldKeysFilterConfig, FieldKeysResponse } from './types';
|
||||
|
||||
export const getFieldKeySuggestions = (
|
||||
filterConfig: FieldKeysFilterConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FieldKeysResponse> =>
|
||||
builderQueryType === 'builder_ai_query'
|
||||
? getAIObservabilityFieldsKeys(filterConfig, signal)
|
||||
: getFieldsKeys(filterConfig, signal);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observability';
|
||||
import { getFieldsValues } from 'api/generated/services/fields';
|
||||
import type { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { FieldValuesFilterConfig, FieldValuesResponse } from './types';
|
||||
|
||||
export const getFieldValueSuggestions = (
|
||||
filterConfig: FieldValuesFilterConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FieldValuesResponse> =>
|
||||
builderQueryType === 'builder_ai_query'
|
||||
? getAIObservabilityFieldsValues(filterConfig, signal)
|
||||
: getFieldsValues(filterConfig, signal);
|
||||
26
frontend/src/api/querySuggestions/types.ts
Normal file
26
frontend/src/api/querySuggestions/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
GetAIObservabilityFieldsKeys200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValues200,
|
||||
GetAIObservabilityFieldsValuesParams,
|
||||
GetFieldsKeys200,
|
||||
GetFieldsKeysParams,
|
||||
GetFieldsValues200,
|
||||
GetFieldsValuesParams,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type FieldKeysFilterConfig =
|
||||
| GetFieldsKeysParams
|
||||
| GetAIObservabilityFieldsKeysParams;
|
||||
|
||||
export type FieldValuesFilterConfig =
|
||||
| GetFieldsValuesParams
|
||||
| GetAIObservabilityFieldsValuesParams;
|
||||
|
||||
export type FieldKeysResponse =
|
||||
| GetFieldsKeys200
|
||||
| GetAIObservabilityFieldsKeys200;
|
||||
|
||||
export type FieldValuesResponse =
|
||||
| GetFieldsValues200
|
||||
| GetAIObservabilityFieldsValues200;
|
||||
@@ -10,6 +10,7 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddedFields from './AddedFields';
|
||||
import { UseFieldApis } from 'types/common/fieldSuggestion';
|
||||
import OtherFields from './OtherFields';
|
||||
|
||||
import styles from './FieldsSelector.module.scss';
|
||||
@@ -31,6 +32,7 @@ interface FieldsSelectorProps {
|
||||
// Lets users add a free-typed field which
|
||||
// does not show up in the suggestions
|
||||
allowCustomFields?: boolean;
|
||||
useFieldApis?: UseFieldApis;
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
@@ -50,6 +52,7 @@ function FieldsSelectorContent({
|
||||
maxFields,
|
||||
requiredFields,
|
||||
allowCustomFields,
|
||||
useFieldApis,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
@@ -158,6 +161,7 @@ function FieldsSelectorContent({
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
allowCustomFields={allowCustomFields}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
|
||||
@@ -3,9 +3,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import {
|
||||
FieldContext,
|
||||
SignalType,
|
||||
@@ -14,6 +12,13 @@ import {
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './FieldsSelector.module.scss';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { UseFieldApis } from 'types/common/fieldSuggestion';
|
||||
import { mergeStaticFields } from 'utils/staticFields';
|
||||
|
||||
const EMPTY_FIELD_APIS: UseFieldApis = {};
|
||||
|
||||
const EMPTY_STATIC_FIELDS: TelemetryFieldKey[] = [];
|
||||
|
||||
interface OtherFieldsProps {
|
||||
signal: DataSource;
|
||||
@@ -22,6 +27,7 @@ interface OtherFieldsProps {
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
allowCustomFields?: boolean;
|
||||
useFieldApis?: UseFieldApis;
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
@@ -31,26 +37,23 @@ function OtherFields({
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
allowCustomFields,
|
||||
useFieldApis = EMPTY_FIELD_APIS,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
signal,
|
||||
searchText: debouncedInputValue,
|
||||
},
|
||||
{
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
const { staticFields = EMPTY_STATIC_FIELDS, ...keysConfig } = useFieldApis;
|
||||
|
||||
const { data: fetchedFields, isFetching } = useFieldKeysSuggestion(
|
||||
keysConfig,
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
);
|
||||
|
||||
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
const suggestions: TelemetryFieldKey[] = mergeStaticFields(
|
||||
staticFields,
|
||||
fetchedFields ?? [],
|
||||
debouncedInputValue,
|
||||
).map((attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
signal: attr.signal as SignalType,
|
||||
@@ -87,7 +90,13 @@ function OtherFields({
|
||||
key: buildCompositeKey(typed, ''),
|
||||
};
|
||||
return [customField, ...available];
|
||||
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
|
||||
}, [
|
||||
staticFields,
|
||||
fetchedFields,
|
||||
addedFields,
|
||||
allowCustomFields,
|
||||
debouncedInputValue,
|
||||
]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { act, fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
|
||||
useFieldKeysSuggestion: jest.fn(() => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -21,22 +27,15 @@ jest.mock('periscope/components/FloatingPanel', () => ({
|
||||
}));
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
|
||||
data: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { UseFieldApis } from 'types/common/fieldSuggestion';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import OtherFields from '../OtherFields';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
|
||||
useFieldKeysSuggestion: jest.fn(() => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
|
||||
data: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,7 +83,6 @@ describe('OtherFields — custom (free-typed) option', () => {
|
||||
mockSuggestions(['orderId']);
|
||||
renderOtherFields({ debouncedInputValue: 'orderid' });
|
||||
|
||||
// the real suggestion shows, the lowercased custom name does not
|
||||
expect(screen.getByText('orderId')).toBeInTheDocument();
|
||||
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -116,10 +116,87 @@ describe('OtherFields — custom (free-typed) option', () => {
|
||||
it('shows the custom option at the field limit but hides its Add button', () => {
|
||||
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
|
||||
|
||||
// same as every other row at the limit: name shown, no Add button
|
||||
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtherFields — useFieldApis', () => {
|
||||
const pool: TelemetryFieldKey[] = [
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
{ name: 'llm_call_count', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
];
|
||||
|
||||
const useFieldApis: UseFieldApis = {
|
||||
builderQueryType: 'builder_ai_query',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
};
|
||||
|
||||
const mockPool = (fields: TelemetryFieldKey[]): void => {
|
||||
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
|
||||
data: fields,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPool(pool);
|
||||
});
|
||||
|
||||
it('lists the pool it is handed', () => {
|
||||
renderOtherFields({ useFieldApis, allowCustomFields: false });
|
||||
|
||||
expect(screen.getByText('total_tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards the fetch params and search to the shared keys hook', () => {
|
||||
renderOtherFields({
|
||||
useFieldApis,
|
||||
allowCustomFields: false,
|
||||
debouncedInputValue: 'llm',
|
||||
});
|
||||
|
||||
expect(useFieldKeysSuggestion).toHaveBeenCalledWith(
|
||||
useFieldApis,
|
||||
DataSource.LOGS,
|
||||
'llm',
|
||||
);
|
||||
});
|
||||
|
||||
it('lists static fields the keys endpoint never returns', () => {
|
||||
mockPool([{ name: 'total_tokens' } as TelemetryFieldKey]);
|
||||
|
||||
renderOtherFields({
|
||||
useFieldApis: {
|
||||
...useFieldApis,
|
||||
staticFields: [{ name: 'last_activity_time' } as TelemetryFieldKey],
|
||||
},
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
|
||||
expect(screen.getByText('total_tokens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits pool fields that are already added', () => {
|
||||
renderOtherFields({
|
||||
useFieldApis,
|
||||
allowCustomFields: false,
|
||||
addedFields: [
|
||||
{
|
||||
name: 'total_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
key: 'trace:total_tokens:float64',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('total_tokens')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { UseFieldApis } from 'types/common/fieldSuggestion';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './ListViewOrderBy.styles.scss';
|
||||
|
||||
const DEFAULT_STATIC_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'timestamp' } as TelemetryFieldKey,
|
||||
];
|
||||
|
||||
const DEFAULT_FIELD_APIS: UseFieldApis = {};
|
||||
|
||||
interface ListViewOrderByProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
dataSource: DataSource;
|
||||
useFieldApis?: UseFieldApis;
|
||||
}
|
||||
|
||||
// Loader component for the dropdown when loading or no results
|
||||
@@ -26,7 +33,9 @@ function ListViewOrderBy({
|
||||
value,
|
||||
onChange,
|
||||
dataSource,
|
||||
useFieldApis = DEFAULT_FIELD_APIS,
|
||||
}: ListViewOrderByProps): JSX.Element {
|
||||
const { staticFields = DEFAULT_STATIC_FIELDS, ...keysConfig } = useFieldApis;
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
const [selectOptions, setSelectOptions] = useState<
|
||||
@@ -35,16 +44,11 @@ function ListViewOrderBy({
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Fetch key suggestions based on debounced input
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
|
||||
queryFn: async () => {
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: debouncedInput,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
const { data, isLoading } = useFieldKeysSuggestion(
|
||||
keysConfig,
|
||||
dataSource,
|
||||
debouncedInput,
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
@@ -55,24 +59,24 @@ function ListViewOrderBy({
|
||||
[],
|
||||
);
|
||||
|
||||
const staticKeysSignature = staticFields.map((field) => field.name).join(',');
|
||||
|
||||
// Update options when API data changes
|
||||
useEffect(() => {
|
||||
const rawKeys: QueryKeyDataSuggestionsProps[] = data?.data?.keys
|
||||
? Object.values(data.data?.keys).flat()
|
||||
: [];
|
||||
const keyNames = (data ?? []).map((field) => field.name);
|
||||
const search = searchInput.trim().toLowerCase();
|
||||
const staticMatches = staticKeysSignature
|
||||
.split(',')
|
||||
.filter((key) => key.length > 0 && key.toLowerCase().includes(search));
|
||||
const uniqueKeys = [...new Set([...staticMatches, ...keyNames])];
|
||||
|
||||
const keyNames = rawKeys.map((key) => key.name);
|
||||
const uniqueKeys = [
|
||||
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
|
||||
];
|
||||
|
||||
const updatedOptions = uniqueKeys.flatMap((key) => [
|
||||
{ label: `${key} (desc)`, value: `${key}:desc` },
|
||||
{ label: `${key} (asc)`, value: `${key}:asc` },
|
||||
]);
|
||||
|
||||
setSelectOptions(updatedOptions);
|
||||
}, [data, searchInput]);
|
||||
setSelectOptions(
|
||||
uniqueKeys.flatMap((key) => [
|
||||
{ label: `${key} (desc)`, value: `${key}:desc` },
|
||||
{ label: `${key} (asc)`, value: `${key}:asc` },
|
||||
]),
|
||||
);
|
||||
}, [data, searchInput, staticKeysSignature]);
|
||||
|
||||
// Handle search input with debounce
|
||||
const handleSearch = (input: string): void => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { TRACE_VIEW_ORDER_BY_FIELDS } from 'container/LLMObservability/Explorer/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import ListViewOrderBy from '../ListViewOrderBy';
|
||||
|
||||
const seenAI: URLSearchParams[] = [];
|
||||
const seenGeneric: URLSearchParams[] = [];
|
||||
|
||||
const mockAIKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(req, res, ctx) => {
|
||||
seenAI.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockGenericKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) => {
|
||||
seenGeneric.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const openDropdown = (): void => {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
};
|
||||
|
||||
const getOptionLabels = (): string[] =>
|
||||
Array.from(document.querySelectorAll('.ant-select-item-option-content')).map(
|
||||
(node) => node.textContent ?? '',
|
||||
);
|
||||
|
||||
describe('ListViewOrderBy', () => {
|
||||
beforeEach(() => {
|
||||
seenAI.length = 0;
|
||||
seenGeneric.length = 0;
|
||||
});
|
||||
|
||||
it('reads the ai_observability trace context for an AI query', async () => {
|
||||
mockAIKeys(['total_tokens']);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="last_activity_time:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
useFieldApis={TRACE_VIEW_ORDER_BY_FIELDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(seenAI).toHaveLength(1);
|
||||
});
|
||||
expect(seenAI[0]?.get('searchText')).toBe('');
|
||||
expect(seenAI[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
expect(seenGeneric).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('offers the static keys alongside the ones the endpoint reports', async () => {
|
||||
mockAIKeys(['total_tokens']);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="last_activity_time:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
useFieldApis={TRACE_VIEW_ORDER_BY_FIELDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOptionLabels()).toContain('total_tokens (desc)');
|
||||
});
|
||||
expect(getOptionLabels()).toContain('last_activity_time (asc)');
|
||||
});
|
||||
|
||||
it('keeps a matching static key while searching', async () => {
|
||||
mockAIKeys([]);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="last_activity_time:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
useFieldApis={TRACE_VIEW_ORDER_BY_FIELDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(seenAI.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
openDropdown();
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: 'activity' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOptionLabels()).toContain('last_activity_time (desc)');
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to timestamp and the generic endpoint', async () => {
|
||||
mockGenericKeys(['service.name']);
|
||||
|
||||
render(
|
||||
<ListViewOrderBy
|
||||
value="timestamp:desc"
|
||||
onChange={jest.fn()}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(seenGeneric).toHaveLength(1);
|
||||
});
|
||||
expect(seenGeneric[0]?.get('signal')).toBe(DataSource.TRACES);
|
||||
expect(seenGeneric[0]?.get('searchText')).toBe('');
|
||||
|
||||
openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOptionLabels()).toContain('timestamp (desc)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'constants/fieldSuggestions';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
OPERATORS,
|
||||
@@ -45,6 +46,13 @@ import { validateQuery } from 'utils/queryValidationUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type {
|
||||
TelemetrytypesGettableFieldKeysDTOKeysAnyOf,
|
||||
TelemetrytypesSourceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
|
||||
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
@@ -52,12 +60,6 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
SuggestedFieldKey,
|
||||
SuggestedFieldKeysByName,
|
||||
} from './fieldSuggestions';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -265,8 +267,10 @@ function QuerySearch({
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
|
||||
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
|
||||
const generateOptions = (
|
||||
keys: TelemetrytypesGettableFieldKeysDTOKeysAnyOf,
|
||||
): any[] =>
|
||||
Object.values(keys).flatMap((items: TelemetrytypesTelemetryFieldKeyDTO[]) =>
|
||||
items.map(({ name, fieldDataType, fieldContext }) => ({
|
||||
label: name,
|
||||
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
|
||||
@@ -319,17 +323,19 @@ function QuerySearch({
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricNamespace,
|
||||
});
|
||||
const response = await getFieldKeySuggestions(
|
||||
{
|
||||
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
source: signalSource as TelemetrytypesSourceDTO,
|
||||
metricNamespace,
|
||||
},
|
||||
queryData.builderQueryType,
|
||||
);
|
||||
|
||||
if (response.data.data) {
|
||||
const { keys } = response.data.data;
|
||||
if (response.data.keys) {
|
||||
const { keys } = response.data;
|
||||
const options = generateOptions(keys);
|
||||
// Deduplicate by full variant identity (name + context + data type), NOT by
|
||||
// label. deduping by label removes varient which is not expected. If we need
|
||||
@@ -497,21 +503,23 @@ function QuerySearch({
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await fetchFieldValuesForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
const responseData = response.data as any;
|
||||
const data = responseData.data || {};
|
||||
const values = data.values || {};
|
||||
: await getFieldValueSuggestions(
|
||||
{
|
||||
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
|
||||
name: key,
|
||||
searchText: sanitizedSearchText,
|
||||
source: signalSource as TelemetrytypesSourceDTO,
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
},
|
||||
queryData.builderQueryType,
|
||||
).then((response) => {
|
||||
const responseData = response.data;
|
||||
const responseDataValues = responseData.values;
|
||||
|
||||
return {
|
||||
stringValues: values.stringValues || [],
|
||||
numberValues: values.numberValues || [],
|
||||
complete: data.complete ?? false,
|
||||
stringValues: responseDataValues.stringValues ?? [],
|
||||
numberValues: responseDataValues.numberValues ?? [],
|
||||
complete: responseData.complete ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
} from '../fieldSuggestions';
|
||||
|
||||
jest.mock('api/generated/services/ai-observability', () => ({
|
||||
getAIObservabilityFieldsKeys: jest.fn(),
|
||||
getAIObservabilityFieldsValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsKeys
|
||||
>;
|
||||
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsValues
|
||||
>;
|
||||
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
const aiValuesResponse = (
|
||||
values: { stringValues?: string[]; numberValues?: number[] } | null,
|
||||
complete = true,
|
||||
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
|
||||
({
|
||||
status: 'success',
|
||||
data: { complete, values },
|
||||
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
|
||||
|
||||
describe('fetchFieldKeysForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const keys = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'llm',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
|
||||
expect(mockedGenericKeys).not.toHaveBeenCalled();
|
||||
expect(keys.data.data).toStrictEqual({
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
mockedGenericKeys.mockResolvedValue({
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as Awaited<ReturnType<typeof getKeySuggestions>>);
|
||||
|
||||
await fetchFieldKeysForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'svc',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).not.toHaveBeenCalled();
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a null ai_observability keys payload to an empty map', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
|
||||
});
|
||||
|
||||
it('passes the generic response through untouched', async () => {
|
||||
const genericResponse = {
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
|
||||
mockedGenericKeys.mockResolvedValue(genericResponse);
|
||||
|
||||
await expect(
|
||||
fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFieldValuesForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIValues.mockResolvedValue(
|
||||
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
|
||||
);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'gen_ai.request.model',
|
||||
searchText: 'gpt',
|
||||
});
|
||||
|
||||
expect(mockedGenericValues).not.toHaveBeenCalled();
|
||||
expect(response).toStrictEqual({
|
||||
data: {
|
||||
data: {
|
||||
complete: true,
|
||||
values: { stringValues: ['gpt-4o'], numberValues: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the key as the name the endpoint expects', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
|
||||
|
||||
await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).toHaveBeenCalledWith({
|
||||
name: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
|
||||
|
||||
await expect(
|
||||
fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'llm_call_count',
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
data: { data: { complete: false, values: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
const genericResponse = {
|
||||
data: {
|
||||
data: { complete: false, values: { stringValues: ['frontend'] } },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
|
||||
mockedGenericValues.mockResolvedValue(genericResponse);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).not.toHaveBeenCalled();
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
}),
|
||||
);
|
||||
expect(response).toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export interface SuggestedFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
|
||||
|
||||
export interface SuggestedFieldKeysPayload {
|
||||
complete: boolean;
|
||||
keys: SuggestedFieldKeysByName;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldKeysResponse {
|
||||
data: { data?: SuggestedFieldKeysPayload };
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesPayload {
|
||||
complete?: boolean;
|
||||
values?: {
|
||||
stringValues?: string[] | null;
|
||||
numberValues?: number[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesResponse {
|
||||
data: { data?: SuggestedFieldValuesPayload };
|
||||
}
|
||||
|
||||
interface FetchFieldKeysParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
metricNamespace?: string;
|
||||
}
|
||||
|
||||
interface FetchFieldValuesParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
key: string;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
}
|
||||
|
||||
export const fetchFieldKeysForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsKeys({ searchText });
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: response.data
|
||||
? { complete: response.data.complete, keys: response.data.keys ?? {} }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchFieldValuesForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsValues({
|
||||
name: key,
|
||||
searchText,
|
||||
});
|
||||
|
||||
return { data: { data: response.data } };
|
||||
}
|
||||
|
||||
// getValueSuggestions' declared response type does not match what the endpoint returns.
|
||||
return getValueSuggestions({
|
||||
signal: dataSource,
|
||||
key,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
}) as unknown as Promise<SuggestedFieldValuesResponse>;
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
|
||||
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import QuerySearch from '../QuerySearch/QuerySearch';
|
||||
@@ -30,17 +35,25 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
|
||||
},
|
||||
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
|
||||
getFieldKeySuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: true, keys: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
|
||||
getFieldValueSuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
boolValues: [],
|
||||
relatedValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -68,8 +81,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
|
||||
it('fetches key suggestions when typing a key (debounced)', async () => {
|
||||
// Use real timers for CodeMirror integration tests
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
const mockedGetKeys = getFieldKeySuggestions as jest.MockedFunction<
|
||||
typeof getFieldKeySuggestions
|
||||
>;
|
||||
mockedGetKeys.mockClear();
|
||||
|
||||
@@ -102,10 +115,22 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
|
||||
it('fetches value suggestions when editing value context', async () => {
|
||||
// Use real timers for CodeMirror integration tests
|
||||
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
const mockedGetValues = getFieldValueSuggestions as jest.MockedFunction<
|
||||
typeof getFieldValueSuggestions
|
||||
>;
|
||||
mockedGetValues.mockClear();
|
||||
mockedGetValues.mockResolvedValueOnce({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: {
|
||||
stringValues: ['payment-service'],
|
||||
numberValues: [200],
|
||||
boolValues: [],
|
||||
relatedValues: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
@@ -129,12 +154,18 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
// the string and number values off the response both reach the dropdown
|
||||
await expect(
|
||||
screen.findByText('payment-service'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('200')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches key suggestions on mount for LOGS', async () => {
|
||||
// Use real timers for CodeMirror integration tests
|
||||
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
const mockedGetKeysOnMount = getFieldKeySuggestions as jest.MockedFunction<
|
||||
typeof getFieldKeySuggestions
|
||||
>;
|
||||
mockedGetKeysOnMount.mockClear();
|
||||
|
||||
@@ -153,6 +184,7 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
() =>
|
||||
expect(mockedGetKeysOnMount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: DataSource.LOGS, searchText: '' }),
|
||||
undefined,
|
||||
),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
@@ -357,8 +389,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
});
|
||||
|
||||
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
const mockedGetKeys = getFieldKeySuggestions as jest.MockedFunction<
|
||||
typeof getFieldKeySuggestions
|
||||
>;
|
||||
mockedGetKeys.mockClear();
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { jest } from '@jest/globals';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
Having,
|
||||
@@ -42,6 +45,22 @@ jest.mock(
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilderOperations');
|
||||
|
||||
const mockFieldKeys = (): void => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// server.resetHandlers() runs after every test, so the handler is re-registered here.
|
||||
beforeEach(() => {
|
||||
mockFieldKeys();
|
||||
});
|
||||
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedUseQueryOperations = jest.mocked(
|
||||
useQueryOperations,
|
||||
|
||||
@@ -31,15 +31,25 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { keys: {} } },
|
||||
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
|
||||
getFieldKeySuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: true, keys: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
|
||||
getFieldValueSuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
boolValues: [],
|
||||
relatedValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'constants/fieldSuggestions';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
@@ -29,15 +26,6 @@ interface UseFieldValuesReturn {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
DataSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
const QUICK_FILTERS_SOURCE_TO_SOURCE: Partial<
|
||||
Record<QuickFiltersSource, TelemetrytypesSourceDTO>
|
||||
> = {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'constants/fieldSuggestions';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import {
|
||||
FieldContext,
|
||||
|
||||
13
frontend/src/constants/fieldSuggestions.ts
Normal file
13
frontend/src/constants/fieldSuggestions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
DataSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
export const FIELD_SUGGESTION_CACHE_TIME = 60_000;
|
||||
@@ -12,6 +12,8 @@ export enum LOCALSTORAGE {
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -106,9 +106,6 @@ export const REACT_QUERY_KEY = {
|
||||
// Dashboard Grid Card Query Keys
|
||||
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
|
||||
|
||||
// Fields Selector Query Keys
|
||||
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
|
||||
|
||||
// AI Assistant Query Keys
|
||||
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
|
||||
} as const;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { UseFieldApis } from 'types/common/fieldSuggestion';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -14,7 +15,8 @@ function TraceExplorerControls({
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
useFieldApis,
|
||||
requiredFields,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
@@ -44,6 +46,8 @@ function TraceExplorerControls({
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
useFieldApis={useFieldApis}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -57,26 +61,24 @@ function TraceExplorerControls({
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
useFieldApis?: UseFieldApis;
|
||||
requiredFields?: readonly string[];
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
config: null,
|
||||
useFieldApis: undefined,
|
||||
requiredFields: undefined,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -10,16 +10,10 @@ import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -28,7 +22,6 @@ import {
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
@@ -37,7 +30,7 @@ import {
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
} from './aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -45,12 +38,10 @@ import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
|
||||
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
|
||||
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
@@ -60,7 +51,6 @@ import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
@@ -72,20 +62,12 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -116,15 +98,13 @@ function Explorer(): JSX.Element {
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryAIWithType,
|
||||
PANEL_TYPES.LIST,
|
||||
DEFAULT_PANEL_TYPE,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
@@ -139,7 +119,7 @@ function Explorer(): JSX.Element {
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
@@ -179,59 +159,6 @@ function Explorer(): JSX.Element {
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -354,14 +281,6 @@ function Explorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -12,25 +12,17 @@ import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
@@ -42,6 +34,7 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
@@ -79,14 +72,6 @@ function ListView({
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
@@ -98,19 +83,6 @@ function ListView({
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -120,7 +92,6 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
@@ -128,7 +99,6 @@ function ListView({
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
@@ -150,7 +120,7 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -158,10 +128,7 @@ function ListView({
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -186,28 +153,20 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
// TODO(ai-explorer): static columns until the preferences framework lands.
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
|
||||
() =>
|
||||
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
|
||||
getFieldColumn(field),
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
[],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
@@ -235,15 +194,9 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
@@ -251,6 +204,8 @@ function ListView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
@@ -258,8 +213,6 @@ function ListView({
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
export const defaultSelectedColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
@@ -1,47 +1,8 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
openInNewTab,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
openInNewTab: boolean;
|
||||
}): any {
|
||||
// Display block to make the whole cell clickable
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{ display: 'block' }}
|
||||
target={openInNewTab ? '_blank' : '_self'}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const transformDataWithDate = (
|
||||
data: QueryDataV3[],
|
||||
): Omit<ILog, 'timestamp'>[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
@@ -60,95 +21,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [
|
||||
{
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
title: 'Timestamp',
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
value / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map((props) => {
|
||||
const name = props?.name || (props as any)?.key;
|
||||
const fieldContext = props?.fieldContext || (props as any)?.type;
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: buildCompositeKey(name, fieldContext),
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'httpMethod' ||
|
||||
name === 'responseStatusCode' ||
|
||||
name === 'response_status_code' ||
|
||||
name === 'http_method'
|
||||
) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{value}
|
||||
</Badge>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'durationNano' || name === 'duration_nano') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>{getMs(value)}ms</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>
|
||||
<LineClampedText text={value} lines={3} />
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
responsive: ['md'],
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
|
||||
@@ -5,8 +5,10 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DEFAULT_PANEL_TYPE } from '../constants';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
|
||||
@@ -107,7 +107,7 @@ function TableView({
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
fileName="ai-traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,6 +126,7 @@ function TimeSeriesViewContainer({
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
exportFileName="ai-traces-timeseries"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,12 @@ function TracesTable({
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
// TanStackTable initialises the column store on mount. If that happens with
|
||||
// columns=[] (rows can land before field keys), empty hiddenColumnIds is
|
||||
// persisted and default-hidden columns stay visible forever.
|
||||
const canMountTable =
|
||||
!isError && !isLoading && columns.length > 0 && data.length !== 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
@@ -83,7 +89,7 @@ function TracesTable({
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{!isError && data.length !== 0 && (
|
||||
{canMountTable && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { buildTraceViewColumns } from '../../TracesView/configs';
|
||||
import TracesTable from '../TracesTable';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
|
||||
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
|
||||
|
||||
const COLUMNS = buildTraceViewColumns([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'start_time' },
|
||||
]);
|
||||
|
||||
function RaceHarness(): JSX.Element {
|
||||
const [columnsReady, setColumnsReady] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setColumnsReady(true)}>
|
||||
columns-ready
|
||||
</button>
|
||||
<TracesTable
|
||||
data={ROWS}
|
||||
columns={columnsReady ? COLUMNS : []}
|
||||
columnStorageKey={STORAGE_KEY}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={(): string => '/trace/abc'}
|
||||
isLoading={!columnsReady}
|
||||
isFetching={false}
|
||||
isError={false}
|
||||
error={null}
|
||||
isFilterApplied={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
};
|
||||
|
||||
describe('TracesTable column-init race', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('does not persist empty defaults when rows land before columns', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<RaceHarness />);
|
||||
|
||||
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
|
||||
|
||||
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set([
|
||||
'timestamp',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'last_activity_time',
|
||||
]);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
@@ -13,6 +20,12 @@ export const STATUS_FIELD_NAMES = new Set([
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
|
||||
export const DURATION_FIELD_NAMES = new Set([
|
||||
'durationNano',
|
||||
'duration_nano',
|
||||
'trace_duration_nano',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
|
||||
@@ -10,6 +10,25 @@
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orderByContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.orderByLabel {
|
||||
color: var(--muted-foreground);
|
||||
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -3,35 +3,43 @@ import {
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { getTraceLink } from '../ListView/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import {
|
||||
TRACE_VIEW_COLUMN_FIELDS,
|
||||
TRACE_VIEW_DEFAULT_ORDER_BY,
|
||||
TRACE_VIEW_ORDER_BY_FIELDS,
|
||||
} from '../constants';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { PER_PAGE_OPTIONS } from './configs';
|
||||
import { useTraceViewColumns } from './useTraceViewColumns';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
@@ -49,6 +57,16 @@ function TracesView({
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
|
||||
|
||||
const {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
} = useTraceViewColumns();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
@@ -60,8 +78,8 @@ function TracesView({
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
|
||||
[stagedQuery],
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
@@ -73,6 +91,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
@@ -81,6 +100,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -142,27 +162,39 @@ function TracesView({
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
const handleOrderChange = useCallback((value: string): void => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
<div className={styles.orderByContainer}>
|
||||
<div className={styles.orderByLabel}>
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
useFieldApis={TRACE_VIEW_ORDER_BY_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
useFieldApis={TRACE_VIEW_COLUMN_FIELDS}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,10 +202,11 @@ function TracesView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isColumnsLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useTraceViewColumns } from '../useTraceViewColumns';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
const AGGREGATE_KEYS = [
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'distinct_tool_count',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
'max_llm_duration_nano',
|
||||
];
|
||||
|
||||
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
|
||||
fields.map((field) => field.name);
|
||||
|
||||
const columnNames = (columns: { header?: unknown }[]): string[] =>
|
||||
columns.map((column) => column.header as string);
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const seenAI: URLSearchParams[] = [];
|
||||
|
||||
const mockAggregateKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(req, res, ctx) => {
|
||||
seenAI.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderColumns = async (): Promise<
|
||||
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
|
||||
> => {
|
||||
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(rendered.result.current.isLoading).toBe(false);
|
||||
});
|
||||
return rendered;
|
||||
};
|
||||
|
||||
describe('useTraceViewColumns', () => {
|
||||
beforeEach(() => {
|
||||
seenAI.length = 0;
|
||||
useColumnStore.getState().tables = {};
|
||||
localStorage.clear();
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
});
|
||||
|
||||
it('reads the aggregates from the trace context of the keys endpoint', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(seenAI).toHaveLength(1);
|
||||
expect(seenAI[0]?.get('searchText')).toBe('');
|
||||
expect(seenAI[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
});
|
||||
|
||||
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toStrictEqual([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'trace_id',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
...AGGREGATE_KEYS,
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects only the default-visible columns on first render', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'trace_id',
|
||||
'llm_call_count',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a newly reported aggregate hidden until it is picked', async () => {
|
||||
mockAggregateKeys(['brand_new_aggregate']);
|
||||
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
|
||||
expect(fieldNames(result.current.selectedFields)).not.toContain(
|
||||
'brand_new_aggregate',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the columns dropped from the selection', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'total_tokens',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a column added back from the pool', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the trace id column even when the selection drops it', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'span_count' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
|
||||
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('persists the selection order', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'total_tokens',
|
||||
'trace_id',
|
||||
'service.name',
|
||||
]);
|
||||
expect(
|
||||
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
|
||||
).toStrictEqual([
|
||||
'trace:total_tokens:float64',
|
||||
'trace_id',
|
||||
'resource:service.name',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,18 +5,29 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
/** Always visible: it is the row's link to the trace. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'llm_call_count',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
TRACE_ID_COLUMN_ID,
|
||||
]);
|
||||
|
||||
export const buildTraceViewColumns = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TableColumnDef<TracesTableRow>[] =>
|
||||
fields.map((field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
|
||||
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
|
||||
enableMove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { mergeStaticFields } from 'utils/staticFields';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
hideColumn,
|
||||
initializeFromDefaults,
|
||||
setColumnOrder,
|
||||
showColumn,
|
||||
useColumnOrder,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TRACE_VIEW_COLUMN_FIELDS } from '../constants';
|
||||
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
const { staticFields: COLUMN_STATIC_FIELDS = [], ...COLUMN_KEYS_CONFIG } =
|
||||
TRACE_VIEW_COLUMN_FIELDS;
|
||||
|
||||
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
|
||||
const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
|
||||
interface UseTraceViewColumns {
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
|
||||
COLUMN_KEYS_CONFIG,
|
||||
DataSource.TRACES,
|
||||
'',
|
||||
);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => mergeStaticFields(COLUMN_STATIC_FIELDS, fetchedFields, ''),
|
||||
[fetchedFields],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildTraceViewColumns(availableFields),
|
||||
[availableFields],
|
||||
);
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isFetched) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
}
|
||||
}, [isFetched, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
|
||||
const selectedFields = useMemo(() => {
|
||||
const hidden = new Set(hiddenColumnIds);
|
||||
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
|
||||
|
||||
return availableFields
|
||||
.filter((field) => !hidden.has(columnIdOf(field)))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? Infinity),
|
||||
);
|
||||
}, [availableFields, hiddenColumnIds, columnOrder]);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
const keptIds = new Set(next.map(columnIdOf));
|
||||
|
||||
columns.forEach((column) => {
|
||||
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
|
||||
showColumn(STORAGE_KEY, column.id);
|
||||
} else {
|
||||
hideColumn(STORAGE_KEY, column.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Columns missing from the order sort last, so the visible ones suffice.
|
||||
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
|
||||
},
|
||||
[columns],
|
||||
);
|
||||
|
||||
return {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { UseFieldApis } from 'types/common/fieldSuggestion';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
@@ -12,13 +26,6 @@ export const TOOLBAR_VIEWS = {
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
@@ -34,3 +41,34 @@ export const TOOLBAR_VIEWS = {
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
|
||||
export const TRACE_VIEW_DEFAULT_ORDER_BY = 'last_activity_time:desc';
|
||||
|
||||
/** Display-only: ordering or filtering on one is an error, so the keys endpoint omits them. */
|
||||
export const AI_O11Y_DISPLAY_ONLY_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'root_span_name' },
|
||||
{ name: 'trace_duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'start_time' },
|
||||
{ name: 'end_time' },
|
||||
{ name: 'error_count' },
|
||||
{ name: 'input' },
|
||||
{ name: 'output' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
const TRACE_VIEW_KEYS = {
|
||||
builderQueryType: 'builder_ai_query',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
} as const;
|
||||
|
||||
export const TRACE_VIEW_ORDER_BY_FIELDS: UseFieldApis = {
|
||||
...TRACE_VIEW_KEYS,
|
||||
staticFields: [{ name: 'last_activity_time' } as TelemetryFieldKey],
|
||||
};
|
||||
|
||||
export const TRACE_VIEW_COLUMN_FIELDS: UseFieldApis = {
|
||||
...TRACE_VIEW_KEYS,
|
||||
staticFields: AI_O11Y_DISPLAY_ONLY_FIELDS,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
@@ -31,31 +30,3 @@ export const getListViewQuery = (
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
stagedQuery: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
|
||||
return getListViewQuery(stagedQuery);
|
||||
}
|
||||
return stagedQuery;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
options: OptionsQuery,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const updatedQuery = cloneDeep(query);
|
||||
set(
|
||||
updatedQuery,
|
||||
'builder.queryData[0].selectColumns',
|
||||
options.selectColumns,
|
||||
);
|
||||
|
||||
return updatedQuery;
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Atom,
|
||||
Filter,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
|
||||
|
||||
import './ToolbarActions.styles.scss';
|
||||
|
||||
interface ToolbarViewItem {
|
||||
name: string;
|
||||
key: string;
|
||||
show?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface LeftToolbarActionsProps {
|
||||
items: any;
|
||||
items: Record<string, ToolbarViewItem>;
|
||||
selectedView: string;
|
||||
onChangeSelectedView: (view: ExplorerViews) => void;
|
||||
showFilter: boolean;
|
||||
@@ -29,8 +31,6 @@ export default function LeftToolbarActions({
|
||||
showFilter,
|
||||
handleFilterVisibilityChange,
|
||||
}: LeftToolbarActionsProps): JSX.Element {
|
||||
const { clickhouse, list, timeseries, table, trace } = items;
|
||||
|
||||
return (
|
||||
<div className="left-toolbar">
|
||||
{!showFilter && (
|
||||
@@ -41,91 +41,34 @@ export default function LeftToolbarActions({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Buttons render in the order the caller declares its views. */}
|
||||
<div className="left-toolbar-query-actions">
|
||||
{list?.show && (
|
||||
<Tooltip title="List View">
|
||||
<Button
|
||||
disabled={list.disabled}
|
||||
className={cx(
|
||||
'list-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === list.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(list.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="search-view" />
|
||||
List View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{Object.values(items).map((item) => {
|
||||
const config = TOOLBAR_VIEW_CONFIG[item?.key];
|
||||
|
||||
{trace?.show && (
|
||||
<Tooltip title="Trace View">
|
||||
<Button
|
||||
disabled={trace.disabled}
|
||||
className={cx(
|
||||
'trace-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === trace.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(trace.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="trace-view" />
|
||||
Trace View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
if (!item?.show || !config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
{timeseries?.show && (
|
||||
<Tooltip title="Time Series">
|
||||
<Button
|
||||
disabled={timeseries.disabled}
|
||||
className={cx(
|
||||
'timeseries-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === timeseries.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(timeseries.key)}
|
||||
>
|
||||
<Atom size={14} data-testid="query-builder-view" />
|
||||
Time Series
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
const { icon: Icon, label, className, testId } = config;
|
||||
|
||||
{clickhouse?.show && (
|
||||
<Tooltip title="Clickhouse">
|
||||
<Button
|
||||
disabled={clickhouse.disabled}
|
||||
className={cx(
|
||||
'clickhouse-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === clickhouse.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(clickhouse.key)}
|
||||
>
|
||||
<Terminal size={14} data-testid="clickhouse-view" />
|
||||
Clickhouse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{table?.show && (
|
||||
<Tooltip title="Table">
|
||||
<Button
|
||||
disabled={table.disabled}
|
||||
className={cx(
|
||||
'table-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === table.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(table.key)}
|
||||
>
|
||||
<Binoculars size={14} data-testid="query-builder-view-v2" />
|
||||
Table
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
return (
|
||||
<Tooltip key={item.key} title={label}>
|
||||
<Button
|
||||
disabled={item.disabled}
|
||||
className={cx(
|
||||
className,
|
||||
'explorer-view-option',
|
||||
selectedView === item.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
|
||||
>
|
||||
<Icon size={14} data-testid={testId} />
|
||||
{label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Atom,
|
||||
Binoculars,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
} from '@signozhq/icons';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
export interface ToolbarViewConfig {
|
||||
icon: typeof Atom;
|
||||
label: string;
|
||||
className: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
|
||||
[ExplorerViews.LIST]: {
|
||||
icon: SquareMousePointer,
|
||||
label: 'List View',
|
||||
className: 'list-view-tab',
|
||||
testId: 'search-view',
|
||||
},
|
||||
[ExplorerViews.TRACE]: {
|
||||
icon: SquareMousePointer,
|
||||
label: 'Trace View',
|
||||
className: 'trace-view-tab',
|
||||
testId: 'trace-view',
|
||||
},
|
||||
[ExplorerViews.TIMESERIES]: {
|
||||
icon: Atom,
|
||||
label: 'Time Series',
|
||||
className: 'timeseries-view-tab',
|
||||
testId: 'query-builder-view',
|
||||
},
|
||||
[ExplorerViews.CLICKHOUSE]: {
|
||||
icon: Terminal,
|
||||
label: 'Clickhouse',
|
||||
className: 'clickhouse-view-tab',
|
||||
testId: 'clickhouse-view',
|
||||
},
|
||||
[ExplorerViews.TABLE]: {
|
||||
icon: Binoculars,
|
||||
label: 'Table',
|
||||
className: 'table-view-tab',
|
||||
testId: 'query-builder-view-v2',
|
||||
},
|
||||
};
|
||||
@@ -92,17 +92,25 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: { keys: {} },
|
||||
},
|
||||
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
|
||||
getFieldKeySuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: true, keys: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
|
||||
getFieldValueSuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
boolValues: [],
|
||||
relatedValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -179,7 +179,10 @@ const setupServer = (capturedPayloads: QueryRangePayloadV5[]): void => {
|
||||
),
|
||||
// Add handler for the fields endpoint that's causing warnings
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, async (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json([])),
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ function TimeSeriesView({
|
||||
panelType = PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart = false,
|
||||
allowExport = false,
|
||||
exportFileName,
|
||||
onYAxisUnitChange,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -270,7 +271,7 @@ function TimeSeriesView({
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={`${dataSource}-timeseries`}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -339,6 +340,7 @@ interface TimeSeriesViewProps {
|
||||
stackBarChart?: boolean;
|
||||
// Opt-in: render the client-side export menu (Logs explorer for now).
|
||||
allowExport?: boolean;
|
||||
exportFileName?: string;
|
||||
// Opt-in: render the y-axis unit selector in the header (views without their
|
||||
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
@@ -351,6 +353,7 @@ TimeSeriesView.defaultProps = {
|
||||
setWarning: undefined,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart: false,
|
||||
exportFileName: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesView;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { FieldKeysResponse } from 'api/querySuggestions/types';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { FieldKeysConfig } from 'types/common/fieldSuggestion';
|
||||
|
||||
import {
|
||||
getFieldKeysQueryOptions,
|
||||
toFieldKeys,
|
||||
} from '../useFieldKeysSuggestion';
|
||||
|
||||
/** Drives the options object the way react-query does, without a client. */
|
||||
const fetchKeys = async (
|
||||
config: FieldKeysConfig,
|
||||
dataSource: DataSource,
|
||||
searchText: string,
|
||||
): Promise<TelemetryFieldKey[]> => {
|
||||
const { queryFn, select } = getFieldKeysQueryOptions(
|
||||
config,
|
||||
dataSource,
|
||||
searchText,
|
||||
);
|
||||
const response = await (
|
||||
queryFn as (context: { signal: AbortSignal }) => Promise<FieldKeysResponse>
|
||||
)({ signal: new AbortController().signal });
|
||||
|
||||
return select?.(response) ?? [];
|
||||
};
|
||||
|
||||
const mockKeys = (
|
||||
path: '/api/v1/ai_observability/fields/keys' | '/api/v1/fields/keys',
|
||||
names: string[],
|
||||
onRequest?: (params: URLSearchParams) => void,
|
||||
): void => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}${path}`, (req, res, ctx) => {
|
||||
onRequest?.(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('useFieldKeysSuggestion', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys(
|
||||
'/api/v1/ai_observability/fields/keys',
|
||||
['total_tokens'],
|
||||
(params) => {
|
||||
seen.push(params);
|
||||
},
|
||||
);
|
||||
|
||||
const keys = await fetchKeys(
|
||||
{
|
||||
builderQueryType: 'builder_ai_query',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
},
|
||||
DataSource.TRACES,
|
||||
'llm',
|
||||
);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.get('searchText')).toBe('llm');
|
||||
expect(seen[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
|
||||
});
|
||||
|
||||
it('reads the generic endpoint for an unmarked query', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys('/api/v1/fields/keys', ['service.name'], (params) => {
|
||||
seen.push(params);
|
||||
});
|
||||
|
||||
const keys = await fetchKeys({}, DataSource.TRACES, 'svc');
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.get('signal')).toBe(DataSource.TRACES);
|
||||
expect(seen[0]?.get('searchText')).toBe('svc');
|
||||
expect(keys.map((key) => key.name)).toStrictEqual(['service.name']);
|
||||
});
|
||||
|
||||
it('reads the trace context of the ai_observability endpoint', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys(
|
||||
'/api/v1/ai_observability/fields/keys',
|
||||
['total_tokens'],
|
||||
(params) => {
|
||||
seen.push(params);
|
||||
},
|
||||
);
|
||||
|
||||
const keys = await fetchKeys(
|
||||
{
|
||||
builderQueryType: 'builder_ai_query',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
},
|
||||
DataSource.TRACES,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(seen[0]?.get('searchText')).toBe('');
|
||||
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
|
||||
});
|
||||
|
||||
it('reuses the cached keys response for a second empty search', async () => {
|
||||
const seen: URLSearchParams[] = [];
|
||||
mockKeys(
|
||||
'/api/v1/ai_observability/fields/keys',
|
||||
['total_tokens'],
|
||||
(params) => {
|
||||
seen.push(params);
|
||||
},
|
||||
);
|
||||
|
||||
const config = {
|
||||
builderQueryType: 'builder_ai_query' as const,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
};
|
||||
|
||||
// Built twice: equal keys must resolve to one cache entry, not two requests.
|
||||
await queryClient.fetchQuery(
|
||||
getFieldKeysQueryOptions(config, DataSource.TRACES, ''),
|
||||
);
|
||||
await queryClient.fetchQuery(
|
||||
getFieldKeysQueryOptions(config, DataSource.TRACES, ''),
|
||||
);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hands the query signal to the fetcher so a superseded search aborts', async () => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
|
||||
res(ctx.delay(500), ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const { queryFn } = getFieldKeysQueryOptions({}, DataSource.LOGS, 'svc');
|
||||
const pending = (
|
||||
queryFn as (context: { signal: AbortSignal }) => Promise<unknown>
|
||||
)({ signal: controller.signal });
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('treats a null keys map as empty', () => {
|
||||
expect(
|
||||
toFieldKeys({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
QueryKey,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
|
||||
import { FIELD_SUGGESTION_CACHE_TIME } from 'constants/fieldSuggestions';
|
||||
import {
|
||||
FieldKeysFilterConfig,
|
||||
FieldKeysResponse,
|
||||
} from 'api/querySuggestions/types';
|
||||
import { FieldKeysConfig } from 'types/common/fieldSuggestion';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
/** One entry per (query type, params) pair; the fetcher picks the endpoint. */
|
||||
const FIELD_KEYS_QUERY_KEY = 'fieldKeysSuggestion';
|
||||
|
||||
export type FieldKeysQueryOptions = UseQueryOptions<
|
||||
FieldKeysResponse,
|
||||
ErrorType<RenderErrorResponseDTO>,
|
||||
TelemetryFieldKey[]
|
||||
> & { queryKey: QueryKey };
|
||||
|
||||
export const toFieldKeys = (
|
||||
res: FieldKeysResponse | undefined,
|
||||
): TelemetryFieldKey[] =>
|
||||
Object.values(res?.data?.keys ?? {})
|
||||
.flat()
|
||||
.map((key) => ({ ...key }) as TelemetryFieldKey);
|
||||
|
||||
export const getFieldKeysQueryOptions = (
|
||||
{
|
||||
builderQueryType,
|
||||
fieldContext,
|
||||
metricName,
|
||||
metricNamespace,
|
||||
signalSource,
|
||||
}: FieldKeysConfig,
|
||||
dataSource: DataSource,
|
||||
searchText: string,
|
||||
): FieldKeysQueryOptions => {
|
||||
const filterConfig: FieldKeysFilterConfig = {
|
||||
signal: dataSource as unknown as TelemetrytypesSignalDTO,
|
||||
searchText,
|
||||
fieldContext,
|
||||
metricName,
|
||||
metricNamespace,
|
||||
source: signalSource as TelemetrytypesSourceDTO | undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
queryKey: [FIELD_KEYS_QUERY_KEY, builderQueryType, filterConfig],
|
||||
queryFn: ({ signal }): Promise<FieldKeysResponse> =>
|
||||
getFieldKeySuggestions(filterConfig, builderQueryType, signal),
|
||||
select: toFieldKeys,
|
||||
staleTime: FIELD_SUGGESTION_CACHE_TIME,
|
||||
cacheTime: FIELD_SUGGESTION_CACHE_TIME,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const useFieldKeysSuggestion = (
|
||||
config: FieldKeysConfig,
|
||||
dataSource: DataSource,
|
||||
searchText: string,
|
||||
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
|
||||
useQuery(getFieldKeysQueryOptions(config, dataSource, searchText));
|
||||
@@ -52,14 +52,24 @@ jest.mock('hooks/useSafeNavigate', () =>
|
||||
.createBrowserHistorySafeNavigateMock(),
|
||||
);
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { data: { keys: {} } } }),
|
||||
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
|
||||
getFieldKeySuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: true, keys: {} },
|
||||
}),
|
||||
}));
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
|
||||
getFieldValueSuggestions: jest.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
boolValues: [],
|
||||
relatedValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
23
frontend/src/types/common/fieldSuggestion.ts
Normal file
23
frontend/src/types/common/fieldSuggestion.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
/** What reaches the keys endpoint. */
|
||||
export interface FieldKeysConfig {
|
||||
builderQueryType?: BuilderQueryType;
|
||||
fieldContext?: TelemetrytypesFieldContextDTO;
|
||||
metricName?: string;
|
||||
metricNamespace?: string;
|
||||
signalSource?: TelemetrytypesSourceDTO | '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-suggestion sources for the pickers: the fetch params plus the manual
|
||||
* fields the endpoint never returns. Unrelated to QuickFilterCheckboxUseFieldApis,
|
||||
* which switches that component between two value APIs.
|
||||
*/
|
||||
export interface UseFieldApis extends FieldKeysConfig {
|
||||
staticFields?: TelemetryFieldKey[];
|
||||
}
|
||||
41
frontend/src/utils/__tests__/staticFields.test.ts
Normal file
41
frontend/src/utils/__tests__/staticFields.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { mergeStaticFields } from '../staticFields';
|
||||
|
||||
describe('mergeStaticFields', () => {
|
||||
it('drops fetched keys that share a name with a static field', () => {
|
||||
expect(
|
||||
mergeStaticFields(
|
||||
[{ name: 'trace_id' } as TelemetryFieldKey],
|
||||
[
|
||||
{ name: 'trace_id' } as TelemetryFieldKey,
|
||||
{ name: 'total_tokens' } as TelemetryFieldKey,
|
||||
],
|
||||
'',
|
||||
).map((key) => key.name),
|
||||
).toStrictEqual(['trace_id', 'total_tokens']);
|
||||
});
|
||||
|
||||
it('filters static fields by search text', () => {
|
||||
expect(
|
||||
mergeStaticFields(
|
||||
[
|
||||
{ name: 'last_activity_time' } as TelemetryFieldKey,
|
||||
{ name: 'timestamp' } as TelemetryFieldKey,
|
||||
],
|
||||
[],
|
||||
'activity',
|
||||
).map((key) => key.name),
|
||||
).toStrictEqual(['last_activity_time']);
|
||||
});
|
||||
|
||||
it('returns the fetched keys when there are no static fields', () => {
|
||||
expect(
|
||||
mergeStaticFields(
|
||||
undefined,
|
||||
[{ name: 'total_tokens' } as TelemetryFieldKey],
|
||||
'',
|
||||
).map((key) => key.name),
|
||||
).toStrictEqual(['total_tokens']);
|
||||
});
|
||||
});
|
||||
16
frontend/src/utils/staticFields.ts
Normal file
16
frontend/src/utils/staticFields.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
/** Manual fields the keys endpoint never returns; the caller owns them, not the fetch. */
|
||||
export const mergeStaticFields = (
|
||||
statics: TelemetryFieldKey[] = [],
|
||||
fetched: TelemetryFieldKey[],
|
||||
searchText: string,
|
||||
): TelemetryFieldKey[] => {
|
||||
const search = searchText.trim().toLowerCase();
|
||||
const staticNames = new Set(statics.map((field) => field.name));
|
||||
|
||||
return [
|
||||
...statics.filter((field) => field.name.toLowerCase().includes(search)),
|
||||
...fetched.filter((field) => !staticNames.has(field.name)),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user