mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-24 21:50:32 +01:00
Compare commits
6 Commits
issue_5955
...
feat/initi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda26ace19 | ||
|
|
41bb064ec2 | ||
|
|
2d0d7a35a1 | ||
|
|
c0444d3064 | ||
|
|
fc32cffe11 | ||
|
|
77fbf74092 |
@@ -64,5 +64,6 @@
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping",
|
||||
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer"
|
||||
}
|
||||
@@ -89,5 +89,6 @@
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping",
|
||||
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer"
|
||||
}
|
||||
@@ -28,6 +28,9 @@ interface FieldsSelectorProps {
|
||||
signal: DataSource;
|
||||
maxFields?: number;
|
||||
requiredFields?: readonly string[];
|
||||
// Lets users add a free-typed field which
|
||||
// does not show up in the suggestions
|
||||
allowCustomFields?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
@@ -46,6 +49,7 @@ function FieldsSelectorContent({
|
||||
signal,
|
||||
maxFields,
|
||||
requiredFields,
|
||||
allowCustomFields,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
@@ -67,7 +71,7 @@ function FieldsSelectorContent({
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
const value = e.target.value.trim();
|
||||
setInputValue(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
@@ -153,6 +157,7 @@ function FieldsSelectorContent({
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
allowCustomFields={allowCustomFields}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
@@ -192,7 +197,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
key: buildCompositeKey(f.name, f.fieldContext),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ interface OtherFieldsProps {
|
||||
addedFields: TelemetryFieldKey[];
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
allowCustomFields?: boolean;
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
@@ -29,6 +30,7 @@ function OtherFields({
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
allowCustomFields,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
@@ -45,25 +47,45 @@ function OtherFields({
|
||||
},
|
||||
);
|
||||
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}),
|
||||
);
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}));
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
addedFields.map((f) => buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
const available = suggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
);
|
||||
}, [data, addedFields]);
|
||||
|
||||
// Prepend the custom field when its name is not in suggestions and
|
||||
// not already added.
|
||||
const typed = debouncedInputValue.trim();
|
||||
const nameMatches = (list: TelemetryFieldKey[]): boolean =>
|
||||
list.some((f) => f.name.toLowerCase() === typed.toLowerCase());
|
||||
const showCustom =
|
||||
!!allowCustomFields &&
|
||||
typed.length > 0 &&
|
||||
!nameMatches(suggestions) &&
|
||||
!nameMatches(addedFields);
|
||||
|
||||
if (!showCustom) {
|
||||
return available;
|
||||
}
|
||||
const customField: TelemetryFieldKey = {
|
||||
name: typed,
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: buildCompositeKey(typed, ''),
|
||||
};
|
||||
return [customField, ...available];
|
||||
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,7 @@ const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
|
||||
signal: 'logs',
|
||||
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
|
||||
fieldDataType: 'string',
|
||||
key: `${fieldContext}.${name}`,
|
||||
key: `${fieldContext}:${name}`,
|
||||
});
|
||||
|
||||
describe('AddedFields — requiredFields', () => {
|
||||
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a', 'log.c']}
|
||||
requiredFields={['log:a', 'log:c']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a']}
|
||||
requiredFields={['log:a']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
requiredFields={['log:body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -101,11 +101,11 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
requiredFields={['log:body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 'log.body' locked, 'log.body_extra' removable.
|
||||
// 'log:body' locked, 'log:body_extra' removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// FloatingPanel is a react-rnd/portal shell — presentation only. Render its
|
||||
// children directly so the test exercises the column-editing behavior.
|
||||
jest.mock('periscope/components/FloatingPanel', () => ({
|
||||
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
const field = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
|
||||
fieldDataType: 'string',
|
||||
});
|
||||
|
||||
const renderPanel = (
|
||||
props: Partial<React.ComponentProps<typeof FieldsSelector>> = {},
|
||||
): { onFieldsChange: jest.Mock } => {
|
||||
const onFieldsChange = jest.fn();
|
||||
render(
|
||||
<FieldsSelector
|
||||
isOpen
|
||||
title="Edit columns"
|
||||
fields={props.fields ?? []}
|
||||
onFieldsChange={onFieldsChange}
|
||||
onClose={jest.fn()}
|
||||
signal={DataSource.LOGS}
|
||||
allowCustomFields
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onFieldsChange };
|
||||
};
|
||||
|
||||
// Type into the search box and flush the 400ms debounce so OtherFields (driven
|
||||
// by the debounced value) recomputes.
|
||||
const typeSearch = (value: string): void => {
|
||||
const input = screen.getByPlaceholderText('Search for a field...');
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value } });
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
};
|
||||
|
||||
describe('FieldsSelector — edit columns (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockSuggestions([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('adds a free-typed field end to end and saves the synthesized key', () => {
|
||||
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
|
||||
|
||||
typeSearch('orderId');
|
||||
|
||||
// custom option surfaces in OTHER FIELDS (only Add button, no suggestions)
|
||||
expect(screen.getByText('orderId')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
});
|
||||
|
||||
// moved into ADDED FIELDS → OTHER FIELDS has nothing left to offer
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
|
||||
// Save commits the draft
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
});
|
||||
|
||||
expect(onFieldsChange).toHaveBeenCalledTimes(1);
|
||||
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
|
||||
expect(saved).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'orderId',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: 'orderId',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a suggested field: it moves from OTHER FIELDS into ADDED FIELDS', () => {
|
||||
mockSuggestions(['service.name']);
|
||||
const { onFieldsChange } = renderPanel({ fields: [] });
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /^add$/i });
|
||||
act(() => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
|
||||
// now removable in ADDED FIELDS, no longer offered in OTHER FIELDS
|
||||
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^add$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
});
|
||||
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
|
||||
expect(saved.map((f) => f.name)).toContain('service.name');
|
||||
});
|
||||
|
||||
it('hides the custom option when the typed name is already added', () => {
|
||||
renderPanel({ fields: [field('orderId')] });
|
||||
|
||||
typeSearch('ORDERID');
|
||||
|
||||
// exact name already added → nothing left to offer in OTHER FIELDS
|
||||
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer a custom option when allowCustomFields is off', () => {
|
||||
renderPanel({ fields: [], allowCustomFields: false });
|
||||
|
||||
typeSearch('unknown.a.b.c');
|
||||
|
||||
// no custom row and nothing addable
|
||||
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^add$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discards an added field, reverting the draft', () => {
|
||||
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
|
||||
|
||||
typeSearch('orderId');
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
});
|
||||
|
||||
// clear the search so the added list is not filtered
|
||||
typeSearch('');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /discard/i }));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('orderId')).not.toBeInTheDocument();
|
||||
expect(onFieldsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { 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 OtherFields from '../OtherFields';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
const renderOtherFields = (
|
||||
props: Partial<React.ComponentProps<typeof OtherFields>> = {},
|
||||
): { onAdd: jest.Mock } => {
|
||||
const onAdd = jest.fn();
|
||||
render(
|
||||
<OtherFields
|
||||
signal={DataSource.LOGS}
|
||||
debouncedInputValue=""
|
||||
addedFields={[]}
|
||||
onAdd={onAdd}
|
||||
isAtLimit={false}
|
||||
allowCustomFields
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onAdd };
|
||||
};
|
||||
|
||||
const addedField = (name: string): TelemetryFieldKey => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: name,
|
||||
});
|
||||
|
||||
describe('OtherFields — custom (free-typed) option', () => {
|
||||
beforeEach(() => {
|
||||
mockSuggestions([]);
|
||||
});
|
||||
|
||||
it('shows a custom option for a typed name that is not a suggestion', () => {
|
||||
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c' });
|
||||
|
||||
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('synthesizes the field with raw name, empty context/type, on add', () => {
|
||||
const { onAdd } = renderOtherFields({ debouncedInputValue: 'orderId' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }));
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
name: 'orderId',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: 'orderId',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the custom option when an exact suggestion exists (case-insensitive)', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('hides the custom option when the name is already added (case-insensitive)', () => {
|
||||
renderOtherFields({
|
||||
debouncedInputValue: 'ORDERID',
|
||||
addedFields: [addedField('orderId')],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the custom option when allowCustomFields is off', () => {
|
||||
renderOtherFields({
|
||||
debouncedInputValue: 'unknown.a.b.c',
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the custom option for an empty input', () => {
|
||||
renderOtherFields({ debouncedInputValue: ' ' });
|
||||
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -51,13 +51,13 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
);
|
||||
|
||||
// body/timestamp appear where the caller placed them, keyed by their
|
||||
// composite IDs ('log.*'); contextless user fields collapse to bare name.
|
||||
// composite IDs ('log:*'); contextless user fields collapse to bare name.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'service.name',
|
||||
'log.body',
|
||||
'log:body',
|
||||
'request.id',
|
||||
'log.timestamp',
|
||||
'log:timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,14 +70,14 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// Attribute variant is its own column, not a duplicate 'log.body'.
|
||||
// Attribute variant is its own column, not a duplicate 'log:body'.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.body',
|
||||
'attribute.body',
|
||||
'log:body',
|
||||
'attribute:body',
|
||||
]);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
|
||||
expect(byId.get('log:body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute:body')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the same distinct-column treatment to timestamp variants', () => {
|
||||
@@ -91,11 +91,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'attribute.timestamp',
|
||||
'log:timestamp',
|
||||
'attribute:timestamp',
|
||||
]);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
|
||||
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute:timestamp')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the synthetic "id" field name', () => {
|
||||
@@ -127,10 +127,10 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// body + timestamp are locked from the table-X removal pathway.
|
||||
expect(byId.get('log.body')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log:body')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log:body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log:timestamp')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
|
||||
// User-added fields stay removable. User field has type='' so composite
|
||||
// collapses to bare name.
|
||||
expect(byId.get('user_field')?.enableRemove).toBe(true);
|
||||
|
||||
@@ -11,6 +11,8 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
|
||||
// Resets button chrome: this was a bare div in the traces explorer.
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function ExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: ExplorerControlsProps): JSX.Element | null {
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
data-testid="explorer-controls-options"
|
||||
>
|
||||
Options
|
||||
<Settings size="md" />
|
||||
</button>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
ExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(ExplorerControls);
|
||||
@@ -1,11 +1,42 @@
|
||||
.explorer {
|
||||
.explorerPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-0);
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
border-right: 0px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> :global(.ant-card-body) {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
&.isFiltersExpanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
.views {
|
||||
padding: var(--spacing-4);
|
||||
// Room for the floating options bar this explorer doesn't render yet.
|
||||
padding-bottom: 60px;
|
||||
margin-bottom: var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,243 @@
|
||||
import styles from './Explorer.module.scss';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { QueryKey, useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
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 { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
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 { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
|
||||
// Shell for the AI Observability Explorer tab. Owns the
|
||||
// /ai-observability/explorer route and is intentionally empty for now: the
|
||||
// query builder + results surface land in a follow-up.
|
||||
import { defaultSelectedColumns, TOOLBAR_VIEWS } from './constants';
|
||||
import styles from './Explorer.module.scss';
|
||||
import ListView from './ListView/ListView';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
import TracesView from './TracesView/TracesView';
|
||||
|
||||
// Forked from the Traces Explorer; diverges as the GenAI query surface lands.
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
handleSetConfig,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// TODO(ai-explorer): destructure `{ options }` when save-view / add-to-dashboard
|
||||
// land (Traces Explorer passes it to getExportQueryData). Until then the call
|
||||
// only seeds `?options=` for views that do not mount ListView.
|
||||
// TODO: shares the Traces Explorer's saved columns; needs its own ai_o11y key.
|
||||
useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<QueryKey>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
void queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// The active view unmounts on cancel, so no child will reset this.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
void logEvent('AI Observability Explorer: Page visited', {});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
// if any of the non-disabled queries has filters applied, return true
|
||||
const result = stagedQuery?.builder?.queryData?.filter(
|
||||
(item) => !isEmpty(item.filters?.items) && !item.disabled,
|
||||
);
|
||||
return !!result?.length;
|
||||
}, [stagedQuery]);
|
||||
|
||||
return (
|
||||
<div className={styles.explorer} data-testid="llm-observability-explorer">
|
||||
<div className={styles.placeholder}>Explorer coming soon.</div>
|
||||
</div>
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={styles.explorerPage}
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<Card className={styles.filter} hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx(styles.explorer, {
|
||||
[styles.isFiltersExpanded]: isOpen,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
leftActions={
|
||||
<LeftToolbarActions
|
||||
showFilter={isOpen}
|
||||
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
|
||||
items={TOOLBAR_VIEWS}
|
||||
selectedView={selectedView}
|
||||
onChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
}
|
||||
warningElement={
|
||||
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>
|
||||
<div className="query-section-container">
|
||||
<QuerySection />
|
||||
</div>
|
||||
</ExplorerCard>
|
||||
|
||||
<div className={styles.views}>
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--typography-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Offset clears the toolbar, query builder, and controls row above the table.
|
||||
.table {
|
||||
max-height: calc(100vh - 360px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
Dispatch,
|
||||
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 logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from '../constants';
|
||||
import ExplorerControls from '../Controls/Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import styles from './ListView.module.scss';
|
||||
import { useListTableColumns } from './useListTableColumns';
|
||||
import { TraceListRow } from '../tableUtils';
|
||||
import { getTraceLink, getTraceRowKey, transformDataWithDate } from './utils';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// TODO: column edits leak to Traces Explorer; needs its own ai_o11y key.
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Query-key slice for selectColumns: stable on reorder, changes on
|
||||
// add/remove/replace. Composite key so resource.foo ≠ attribute.foo.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => buildCompositeKey(c.name, c.fieldContext))
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const dataLength =
|
||||
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useListTableColumns(options?.selectColumns || []);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TraceListRow>[]): void => {
|
||||
// Column ids are composite (fieldContext.name) — disambiguates same-name fields.
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TraceListRow): void => {
|
||||
safeNavigate(getTraceLink(row));
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback((row: TraceListRow): void => {
|
||||
window.open(getAbsoluteUrl(getTraceLink(row)), '_blank');
|
||||
}, []);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
void logEvent('AI Observability Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controls}>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<ExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<TanStackTable<TraceListRow>
|
||||
data={transformedQueryTableData}
|
||||
columns={columns}
|
||||
className={styles.table}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
isLoading={isFetching}
|
||||
getRowKey={getTraceRowKey}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
disableVirtualScroll
|
||||
testId="ai-observability-list-view-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Older callers passed `{ key, type }` where v5 uses `{ name, fieldContext }`. */
|
||||
interface LegacyFieldKey {
|
||||
key?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const BADGE_FIELDS = new Set([
|
||||
'httpMethod',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http_method',
|
||||
]);
|
||||
|
||||
const DURATION_FIELDS = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
const TIMESTAMP_COLUMN_ID = 'date';
|
||||
|
||||
/** Cells must tolerate missing values: skeleton rows pass through them. */
|
||||
export function useListTableColumns(
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
): TableColumnDef<TraceListRow>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return useMemo<TableColumnDef<TraceListRow>[]>(() => {
|
||||
const timestampColumn: TableColumnDef<TraceListRow> = {
|
||||
id: TIMESTAMP_COLUMN_ID,
|
||||
header: 'Timestamp',
|
||||
accessorFn: (row): unknown => row?.date,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
width: { default: 180, min: 180 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const timestamp = value as string | number | undefined;
|
||||
if (timestamp === undefined || timestamp === null) {
|
||||
return <TanStackTable.Text> </TanStackTable.Text>;
|
||||
}
|
||||
const formatted =
|
||||
typeof timestamp === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
timestamp,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
timestamp / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
|
||||
const fieldColumns = selectedColumns.map(
|
||||
(field): TableColumnDef<TraceListRow> => {
|
||||
const legacy = field as TelemetryFieldKey & LegacyFieldKey;
|
||||
const name = field?.name || legacy?.key || '';
|
||||
const fieldContext = field?.fieldContext || legacy?.type;
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row?.[name],
|
||||
enableRemove: false,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return <TanStackTable.Text data-testid={name}>N/A</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
if (BADGE_FIELDS.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{formatCellValue(value)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (DURATION_FIELDS.has(name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>
|
||||
{getMs(formatCellValue(value))}ms
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-testid={name}>
|
||||
<LineClampedText text={formatCellValue(value)} lines={3} />
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return [timestampColumn, ...fieldColumns];
|
||||
}, [selectedColumns, formatTimezoneAdjustedTimestamp]);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Rows carry the span's attributes plus `date` (the list item's timestamp). */
|
||||
export const transformDataWithDate = (data: QueryDataV3[]): TraceListRow[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: TraceListRow): string =>
|
||||
`${ROUTES.TRACE}/${formatCellValue(record.traceID || record.trace_id)}${formUrlParams(
|
||||
{
|
||||
spanId: record.spanID || record.span_id,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
},
|
||||
)}`;
|
||||
|
||||
/** Row identity: span id per row, trace id as the root-only fallback. */
|
||||
export const getTraceRowKey = (record: TraceListRow): string =>
|
||||
formatCellValue(
|
||||
record?.spanID ?? record?.span_id ?? record?.traceID ?? record?.trace_id,
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuerySection);
|
||||
@@ -0,0 +1,7 @@
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: var(--spacing-6);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import styles from './TableView.module.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TABLE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableData = useMemo(
|
||||
() =>
|
||||
data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload.data.result ||
|
||||
[],
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className={styles.header}>
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
queryTableData={queryTableData as QueryDataV3[]}
|
||||
loading={isLoading}
|
||||
sticky
|
||||
/>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap[dataSource],
|
||||
graphType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
dataSource?: DataSource;
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
@@ -0,0 +1,19 @@
|
||||
.loadingTraces {
|
||||
padding: var(--spacing-12) 0;
|
||||
height: 240px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gif {
|
||||
height: 72px;
|
||||
margin-left: calc(var(--spacing-12) * -1);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import styles from './TraceLoading.module.scss';
|
||||
|
||||
export function TracesLoading(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingTraces}>
|
||||
<div className={styles.content}>
|
||||
<img className={styles.gif} src={loadingPlaneUrl} alt="wait-icon" />
|
||||
|
||||
<Typography>Retrieving your traces!</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.table {
|
||||
max-height: calc(100vh - 330px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} 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 ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { PER_PAGE_OPTIONS } from '../constants';
|
||||
import ExplorerControls from '../Controls/Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { TraceListRow } from '../tableUtils';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
import { getRootSpanRowKey } from './utils';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
(): TraceListRow[] => responseData?.map((listItem) => listItem.data) ?? [],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && tableData.length !== 0) {
|
||||
void logEvent('AI Observability Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{tableData.length !== 0 && (
|
||||
<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={styles.controls}>
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<ExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && tableData.length === 0)) && <TracesLoading />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
tableData.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
tableData.length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{tableData.length !== 0 && (
|
||||
<TanStackTable<TraceListRow>
|
||||
data={tableData}
|
||||
columns={columns}
|
||||
className={styles.table}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
isLoading={isLoading}
|
||||
getRowKey={getRootSpanRowKey}
|
||||
disableVirtualScroll
|
||||
testId="ai-observability-traces-view-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Fixed root-span columns — no user selection, so nothing can be removed. */
|
||||
export const columns: TableColumnDef<TraceListRow>[] = [
|
||||
{
|
||||
id: 'serviceName',
|
||||
header: 'Root Service Name',
|
||||
accessorFn: (row): unknown => row?.['service.name'],
|
||||
enableRemove: false,
|
||||
width: { min: 200 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Root Operation Name',
|
||||
accessorFn: (row): unknown => row?.name,
|
||||
enableRemove: false,
|
||||
width: { min: 260 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'durationNano',
|
||||
header: 'Root Duration (in ms)',
|
||||
accessorFn: (row): unknown => row?.duration_nano,
|
||||
enableRemove: false,
|
||||
width: { min: 170 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>
|
||||
{value === undefined || value === null
|
||||
? ''
|
||||
: `${getMs(formatCellValue(value))}ms`}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'span_count',
|
||||
header: 'No of Spans',
|
||||
accessorFn: (row): unknown => row?.span_count,
|
||||
enableRemove: false,
|
||||
width: { min: 120 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'traceID',
|
||||
header: 'TraceID',
|
||||
accessorFn: (row): unknown => row?.trace_id,
|
||||
enableRemove: false,
|
||||
width: { min: 290 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const traceID = formatCellValue(value);
|
||||
if (!traceID) {
|
||||
return <TanStackTable.Text> </TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: traceID })}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Row identity for root spans: one row per trace; tolerates skeleton rows. */
|
||||
export const getRootSpanRowKey = (record: TraceListRow): string =>
|
||||
formatCellValue(record?.trace_id ?? record?.traceID);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
show: true,
|
||||
key: 'list',
|
||||
},
|
||||
timeseries: {
|
||||
name: 'timeseries',
|
||||
label: 'Timeseries',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'table',
|
||||
},
|
||||
clickhouse: {
|
||||
name: 'clickhouse',
|
||||
label: 'Clickhouse',
|
||||
disabled: false,
|
||||
show: false,
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
|
||||
//TODO: Change this later
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = DEFAULT_PER_PAGE_OPTIONS;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
stagedQuery: Query,
|
||||
orderBy?: string,
|
||||
): Query => {
|
||||
const query = stagedQuery
|
||||
? cloneDeep(stagedQuery)
|
||||
: cloneDeep(initialQueriesMap.traces);
|
||||
|
||||
const orderByPayload: OrderByPayload[] = orderBy
|
||||
? [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
for (let i = 0; i < query.builder.queryData.length; i++) {
|
||||
const queryData = query.builder.queryData[i];
|
||||
queryData.groupBy = [];
|
||||
queryData.having = {
|
||||
expression: '',
|
||||
};
|
||||
queryData.orderBy = orderByPayload;
|
||||
}
|
||||
|
||||
if (
|
||||
query.builder.queryTraceOperator &&
|
||||
query.builder.queryTraceOperator.length > 0
|
||||
) {
|
||||
for (let i = 0; i < query.builder.queryTraceOperator.length; i++) {
|
||||
const queryTraceOperator = query.builder.queryTraceOperator[i];
|
||||
queryTraceOperator.groupBy = [];
|
||||
queryTraceOperator.having = {
|
||||
expression: '',
|
||||
};
|
||||
queryTraceOperator.orderBy = orderByPayload;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Span attributes, flattened. Every field access must tolerate undefined. */
|
||||
export type TraceListRow = Record<string, unknown>;
|
||||
|
||||
/** Renders a row value as text; objects are JSON-serialised. */
|
||||
export const formatCellValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -18,6 +18,12 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
// Same for the Explorer tab, which renders a full query-builder surface.
|
||||
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
|
||||
@@ -275,6 +275,7 @@ function LiveLogsContainer({
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.LOGS}
|
||||
requiredFields={LOGS_REQUIRED_COLUMNS}
|
||||
allowCustomFields
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -113,6 +113,7 @@ function LogsActionsContainer({
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.LOGS}
|
||||
requiredFields={LOGS_REQUIRED_COLUMNS}
|
||||
allowCustomFields
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -296,12 +296,12 @@ describe('useOptionsMenu', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// New order: [attribute.service.name, log.body, resource.service.name, log.timestamp]
|
||||
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'attribute.service.name',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'log.timestamp',
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'log:timestamp',
|
||||
]);
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
@@ -309,13 +309,13 @@ describe('useOptionsMenu', () => {
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'attribute.service.name',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'log.timestamp',
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'log:timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -329,11 +329,11 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'log:timestamp',
|
||||
'unknown.composite',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'attribute.service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name',
|
||||
]);
|
||||
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
@@ -341,13 +341,13 @@ describe('useOptionsMenu', () => {
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'log.timestamp',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'attribute.service.name',
|
||||
'log:timestamp',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -359,17 +359,17 @@ describe('useOptionsMenu', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Removing 'resource.service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource.service.name');
|
||||
// Removing 'resource:service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource:service.name');
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
expect(
|
||||
remaining.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']);
|
||||
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
|
||||
});
|
||||
|
||||
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getOptionsFromKeys = (
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
|
||||
// different fieldContexts (e.g. resource:service.name vs attribute:service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}.${name}` : name;
|
||||
context ? `${context}:${name}` : name;
|
||||
|
||||
Reference in New Issue
Block a user