mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
4 Commits
test/uplot
...
feat/saved
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba761e3fb6 | ||
|
|
d412e6bc4c | ||
|
|
119ab97240 | ||
|
|
e163ec3b1b |
@@ -14,6 +14,8 @@ export enum LOCALSTORAGE {
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_OPTIONS = 'AI_OBSERVABILITY_LIST_OPTIONS',
|
||||
AI_OBSERVABILITY_TRACE_OPTIONS = 'AI_OBSERVABILITY_TRACE_OPTIONS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
BottomStrip: 'bottom_strip',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
@@ -37,6 +44,14 @@ function AskNoz(): JSX.Element | null {
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
const handleOpen = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: AIAssistantOpenSource.BottomStrip,
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openAIAssistant();
|
||||
}, [pathname]);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
onClick={handleOpen}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
@@ -6,6 +11,10 @@ import { render } from 'tests/test-utils';
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
@@ -91,6 +100,17 @@ describe('AskNoz', () => {
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the open as coming from the strip', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
AIAssistantEvents.Opened,
|
||||
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
BottomStripEventKeys,
|
||||
BottomStripEvents,
|
||||
BottomStripSupportType,
|
||||
} from '../events';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
const needsCard = chatSupport === ChatSupportState.NeedsCard;
|
||||
void logEvent(BottomStripEvents.SupportClicked, {
|
||||
[BottomStripEventKeys.Type]: needsCard
|
||||
? BottomStripSupportType.AddCard
|
||||
: BottomStripSupportType.Pylon,
|
||||
});
|
||||
if (needsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BottomStripEvents, BottomStripSupportType } from '../../events';
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
@@ -37,6 +44,9 @@ describe('SupportButton', () => {
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.Pylon,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.AddCard,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
12
frontend/src/container/BottomStrip/events.ts
Normal file
12
frontend/src/container/BottomStrip/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum BottomStripEvents {
|
||||
SupportClicked = 'Bottom Strip: Support clicked',
|
||||
}
|
||||
|
||||
export enum BottomStripEventKeys {
|
||||
Type = 'type',
|
||||
}
|
||||
|
||||
export enum BottomStripSupportType {
|
||||
Pylon = 'pylon',
|
||||
AddCard = 'add_card',
|
||||
}
|
||||
@@ -37,11 +37,8 @@ import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import { PER_PAGE_OPTIONS, TIMESTAMP_FIELD } from './configs';
|
||||
import { useListViewColumns } from './useListViewColumns';
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
@@ -77,6 +74,23 @@ function ListView({
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const {
|
||||
selectedFields,
|
||||
fieldsSelectorConfig,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
} = useListViewColumns();
|
||||
|
||||
// Sorted names: stable on reorder, changes on add/remove (the API only returns selected columns).
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
selectedFields
|
||||
.map((field) => field.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[selectedFields],
|
||||
);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
@@ -97,12 +111,14 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
],
|
||||
@@ -123,7 +139,7 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: defaultSelectedColumns,
|
||||
selectColumns: selectedFields,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -156,13 +172,13 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
// TODO(ai-explorer): static columns until the preferences framework lands.
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
|
||||
() =>
|
||||
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
|
||||
getFieldColumn(field),
|
||||
),
|
||||
[],
|
||||
[
|
||||
TIMESTAMP_FIELD,
|
||||
...selectedFields.filter((field) => field.name !== TIMESTAMP_FIELD.name),
|
||||
].map((field) => getFieldColumn(field)),
|
||||
[selectedFields],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
@@ -204,6 +220,7 @@ function ListView({
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +228,6 @@ function ListView({
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
@@ -219,6 +235,8 @@ function ListView({
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { getFieldColumn } from '../../TracesTable/getFieldColumn';
|
||||
import { defaultSelectedColumns } from '../configs';
|
||||
import { LAST_COLUMN_ERROR, useListViewColumns } from '../useListViewColumns';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { error: jest.fn(), success: jest.fn() },
|
||||
}));
|
||||
|
||||
const [SERVICE_NAME, SPAN_NAME, DURATION] = defaultSelectedColumns;
|
||||
const MODEL = {
|
||||
name: 'gen_ai.request.model',
|
||||
signal: 'traces',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const {
|
||||
selectedFields,
|
||||
fieldsSelectorConfig,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
} = useListViewColumns();
|
||||
const removeFirst = (): void =>
|
||||
onColumnRemove(getFieldColumn(selectedFields[0]).id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="columns">
|
||||
{selectedFields.map((field) => field.name).join(',')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
fieldsSelectorConfig.fieldsSelector?.onFieldsChange([SPAN_NAME, MODEL])
|
||||
}
|
||||
>
|
||||
save drawer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
onColumnOrderChange(
|
||||
[DURATION, SERVICE_NAME, SPAN_NAME].map((field) => getFieldColumn(field)),
|
||||
)
|
||||
}
|
||||
>
|
||||
reorder
|
||||
</button>
|
||||
<button type="button" onClick={removeFirst}>
|
||||
remove first
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHarness = (): void => {
|
||||
render(<Harness />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
});
|
||||
};
|
||||
|
||||
describe('useListViewColumns', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('falls back to the default columns when nothing is stored', () => {
|
||||
renderHarness();
|
||||
|
||||
expect(screen.getByTestId('columns')).toHaveTextContent(
|
||||
defaultSelectedColumns.map((field) => field.name).join(','),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the drawer selection', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'save drawer' }));
|
||||
|
||||
expect(screen.getByTestId('columns')).toHaveTextContent(
|
||||
'name,gen_ai.request.model',
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the table order on a header drag, keeping unlisted columns last', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'reorder' }));
|
||||
|
||||
expect(screen.getByTestId('columns')).toHaveTextContent(
|
||||
/^duration_nano,service\.name,name,/,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a column from the header but never the last one', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
await user.click(screen.getByRole('button', { name: 'save drawer' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'remove first' }));
|
||||
expect(screen.getByTestId('columns')).toHaveTextContent(
|
||||
/^gen_ai\.request\.model$/,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'remove first' }));
|
||||
expect(screen.getByTestId('columns')).toHaveTextContent(
|
||||
/^gen_ai\.request\.model$/,
|
||||
);
|
||||
expect(jest.mocked(toast.error)).toHaveBeenCalledWith(LAST_COLUMN_ERROR);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { defaultSelectedColumns } from './configs';
|
||||
|
||||
export const LAST_COLUMN_ERROR = 'There must be at least one selected column';
|
||||
|
||||
const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
|
||||
interface UseListViewColumns {
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
fieldsSelectorConfig: OptionsMenuConfig;
|
||||
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove: (columnId: string) => void;
|
||||
}
|
||||
|
||||
export function useListViewColumns(): UseListViewColumns {
|
||||
const {
|
||||
aiObservability: { columns, updateColumns },
|
||||
} = usePreferenceContext();
|
||||
|
||||
const selectedFields = columns.list ?? defaultSelectedColumns;
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => updateColumns('list', next),
|
||||
[updateColumns],
|
||||
);
|
||||
|
||||
const fieldsSelectorConfig = useMemo<OptionsMenuConfig>(
|
||||
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
const onColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
|
||||
updateColumns(
|
||||
'list',
|
||||
[...selectedFields].sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? Infinity),
|
||||
),
|
||||
);
|
||||
},
|
||||
[selectedFields, updateColumns],
|
||||
);
|
||||
|
||||
const onColumnRemove = useCallback(
|
||||
(columnId: string): void => {
|
||||
const next = selectedFields.filter(
|
||||
(field) => columnIdOf(field) !== columnId,
|
||||
);
|
||||
if (!next.length) {
|
||||
toast.error(LAST_COLUMN_ERROR);
|
||||
return;
|
||||
}
|
||||
updateColumns('list', next);
|
||||
},
|
||||
[selectedFields, updateColumns],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedFields,
|
||||
fieldsSelectorConfig,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,8 @@ function TracesView({
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
canPersistColumns,
|
||||
@@ -183,15 +185,6 @@ function TracesView({
|
||||
[canPersistColumns, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? columns
|
||||
: columns.filter((column) => column.defaultVisibility !== false),
|
||||
[canPersistColumns, columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
@@ -226,13 +219,12 @@ function TracesView({
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
columns={columns}
|
||||
columnStorageKey={
|
||||
canPersistColumns
|
||||
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
|
||||
: undefined
|
||||
}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading || isColumnsLoading}
|
||||
@@ -240,6 +232,8 @@ function TracesView({
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
@@ -40,15 +41,51 @@ export const sortByDefaultOrder = (
|
||||
): TelemetryFieldKey[] =>
|
||||
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
|
||||
|
||||
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
|
||||
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied while no selection is stored. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
|
||||
|
||||
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
|
||||
export const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
|
||||
export const getDefaultTraceViewFields = (
|
||||
availableFields: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] =>
|
||||
availableFields.filter((field) => DEFAULT_VISIBLE_FIELDS.has(field.name));
|
||||
|
||||
/** The selection the old column store encoded; `null` when it matches the defaults. */
|
||||
export const getLegacyTraceViewSelection = (
|
||||
availableFields: TelemetryFieldKey[],
|
||||
hiddenColumnIds: string[],
|
||||
columnOrder: string[],
|
||||
): TelemetryFieldKey[] | null => {
|
||||
const hidden = new Set(hiddenColumnIds);
|
||||
|
||||
// A saved order lists exactly the columns the user kept.
|
||||
if (columnOrder.length) {
|
||||
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
|
||||
const kept = availableFields
|
||||
.filter((field) => orderIndex.has(columnIdOf(field)))
|
||||
.filter((field) => !hidden.has(columnIdOf(field)));
|
||||
return kept.length
|
||||
? kept.sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? 0) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? 0),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
const defaults = getDefaultTraceViewFields(availableFields);
|
||||
const kept = defaults.filter((field) => !hidden.has(columnIdOf(field)));
|
||||
return kept.length === defaults.length ? null : kept;
|
||||
};
|
||||
|
||||
export const buildTraceViewColumns = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TableColumnDef<TracesTableRow>[] =>
|
||||
fields.map((field) => ({
|
||||
...getFieldColumn(field),
|
||||
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
|
||||
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
|
||||
enableMove: true,
|
||||
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { mergeExtraFields } from 'utils/extraFields';
|
||||
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 { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
@@ -23,27 +21,41 @@ import {
|
||||
} from '../constants';
|
||||
import {
|
||||
buildTraceViewColumns,
|
||||
columnIdOf,
|
||||
getDefaultTraceViewFields,
|
||||
getLegacyTraceViewSelection,
|
||||
sortByDefaultOrder,
|
||||
TRACE_ID_COLUMN_ID,
|
||||
} from './configs';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
|
||||
const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
/** Now only column widths; it held the selection as hidden ids + order before the preference slice. */
|
||||
const TABLE_STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
interface UseTraceViewColumns {
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove: (columnId: string) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
|
||||
canPersistColumns: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
const withTraceId = (
|
||||
fields: TelemetryFieldKey[],
|
||||
availableFields: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] => {
|
||||
if (fields.some((field) => field.name === TRACE_ID_COLUMN_ID)) {
|
||||
return fields;
|
||||
}
|
||||
const traceId = availableFields.find(
|
||||
(field) => field.name === TRACE_ID_COLUMN_ID,
|
||||
);
|
||||
return traceId ? [traceId, ...fields] : fields;
|
||||
};
|
||||
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const {
|
||||
data: fetchedFields = [],
|
||||
@@ -66,60 +78,99 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
[fetchedFields],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildTraceViewColumns(availableFields),
|
||||
[availableFields],
|
||||
const {
|
||||
aiObservability: {
|
||||
columns: { trace: storedFields },
|
||||
updateColumns,
|
||||
},
|
||||
} = usePreferenceContext();
|
||||
|
||||
// Captured on first render, before the table prunes hidden ids it no longer renders.
|
||||
const legacyHiddenIds = useHiddenColumnIds(TABLE_STORAGE_KEY);
|
||||
const legacyOrder = useColumnOrder(TABLE_STORAGE_KEY);
|
||||
const legacy = useRef({ hiddenIds: legacyHiddenIds, order: legacyOrder });
|
||||
|
||||
const migratedFields = useMemo(
|
||||
() =>
|
||||
isSuccess && !storedFields
|
||||
? getLegacyTraceViewSelection(
|
||||
availableFields,
|
||||
legacy.current.hiddenIds,
|
||||
legacy.current.order,
|
||||
)
|
||||
: null,
|
||||
[isSuccess, storedFields, availableFields],
|
||||
);
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
if (!migratedFields) {
|
||||
return;
|
||||
}
|
||||
}, [isSuccess, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
updateColumns('trace', migratedFields);
|
||||
// Clear the old encoding so a later reset to defaults isn't migrated again.
|
||||
setColumnOrder(TABLE_STORAGE_KEY, []);
|
||||
legacy.current.hiddenIds.forEach((id) => showColumn(TABLE_STORAGE_KEY, id));
|
||||
legacy.current = { hiddenIds: [], order: [] };
|
||||
}, [migratedFields, updateColumns]);
|
||||
|
||||
const selectedFields = useMemo(() => {
|
||||
const hidden = new Set(hiddenColumnIds);
|
||||
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
|
||||
const byName = new Map(availableFields.map((field) => [field.name, field]));
|
||||
const source =
|
||||
storedFields ?? migratedFields ?? getDefaultTraceViewFields(availableFields);
|
||||
// Stored fields the endpoint no longer reports are dropped.
|
||||
const resolved = source
|
||||
.map((field) => byName.get(field.name))
|
||||
.filter((field): field is TelemetryFieldKey => !!field);
|
||||
return withTraceId(resolved, availableFields);
|
||||
}, [availableFields, storedFields, migratedFields]);
|
||||
|
||||
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 columns = useMemo(
|
||||
() => buildTraceViewColumns(selectedFields),
|
||||
[selectedFields],
|
||||
);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
updateColumns('trace', withTraceId(next, availableFields));
|
||||
},
|
||||
[columns, isSuccess],
|
||||
[isSuccess, availableFields, updateColumns],
|
||||
);
|
||||
|
||||
const onColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
|
||||
onFieldsChange(
|
||||
[...selectedFields].sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? Infinity),
|
||||
),
|
||||
);
|
||||
},
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
const onColumnRemove = useCallback(
|
||||
(columnId: string): void => {
|
||||
if (columnId === TRACE_ID_COLUMN_ID) {
|
||||
return;
|
||||
}
|
||||
onFieldsChange(
|
||||
selectedFields.filter((field) => columnIdOf(field) !== columnId),
|
||||
);
|
||||
},
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
return {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
canPersistColumns: isSuccess,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
@@ -54,7 +55,17 @@ function SavedViewsHeader({
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useSaveNewView(createView);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const handleSave = useCallback(
|
||||
(displayName: string): Promise<boolean> =>
|
||||
saveNewView(
|
||||
displayName,
|
||||
modalMode === 'saveAsNew'
|
||||
? SavedViewCreatedFrom.SaveAsNew
|
||||
: SavedViewCreatedFrom.Header,
|
||||
),
|
||||
[saveNewView, modalMode],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
|
||||
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
|
||||
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
|
||||
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
|
||||
@@ -35,7 +36,7 @@ function SavedViewsList({
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
const { selectView, clearView, resetView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
@@ -102,10 +103,10 @@ function SavedViewsList({
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
clearView();
|
||||
resetView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
}, [pendingAction, deleteView, viewKey, resetView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -141,7 +142,9 @@ function SavedViewsList({
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const source = SavedviewtypesSourceDTO.traces;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function renderAll(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
|
||||
<SavedViewsPanel source={source} onClose={jest.fn()} />
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
// Buttons and menu rows ignore clicks until their permission check settles.
|
||||
async function clickWhenUsable(testId: string): Promise<void> {
|
||||
await waitFor(() => expectUsable(screen.getByTestId(testId)));
|
||||
await userEvent.click(screen.getByTestId(testId));
|
||||
}
|
||||
|
||||
async function chooseMenuItem(testId: string): Promise<void> {
|
||||
const item = await screen.findByTestId(testId);
|
||||
await waitFor(() => expectRowUsable(item), MENU_CHECK_TIMEOUT);
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
async function chooseFromRowMenu(viewName: string, key: string): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
|
||||
await chooseMenuItem(`saved-views-row-menu-item-${key}`);
|
||||
}
|
||||
|
||||
async function chooseFromSaveMenu(key: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await chooseMenuItem(`saved-views-save-changes-item-${key}`);
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), name);
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
}
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
const callsOf = (event: SavedViewEvents): unknown[][] =>
|
||||
logEventMock.mock.calls.filter(([name]) => name === event);
|
||||
|
||||
describe('SavedViews analytics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
logEventMock.mockClear();
|
||||
jest.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
it('logs a view created from the header', async () => {
|
||||
mockSavedViewsApi([errors], { createdId: 'view-new' });
|
||||
renderAll();
|
||||
|
||||
await clickWhenUsable('saved-views-create');
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-new',
|
||||
from: SavedViewCreatedFrom.Header,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a view created from the empty state', async () => {
|
||||
mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
renderAll();
|
||||
|
||||
await screen.findByTestId('saved-views-list-create');
|
||||
await clickWhenUsable('saved-views-list-create');
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-new',
|
||||
from: SavedViewCreatedFrom.EmptyState,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log a create that fails', async () => {
|
||||
mockSavedViewsApi([errors], { createStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await clickWhenUsable('saved-views-create');
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('on a changed view', () => {
|
||||
async function openDirtyView(): Promise<void> {
|
||||
const { history } = renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await screen.findByTestId('saved-views-discard');
|
||||
}
|
||||
|
||||
it('does not log an update that fails', async () => {
|
||||
mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
await openDirtyView();
|
||||
|
||||
await chooseFromSaveMenu('update');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewUpdated)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs an update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView();
|
||||
|
||||
await chooseFromSaveMenu('update');
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a view created with save as new', async () => {
|
||||
mockSavedViewsApi([errors], { createdId: 'view-copy' });
|
||||
await openDirtyView();
|
||||
|
||||
await chooseFromSaveMenu('save-as-new');
|
||||
await submitName('Errors copy');
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-copy',
|
||||
from: SavedViewCreatedFrom.SaveAsNew,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs discarded changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView();
|
||||
logEventMock.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a cleared view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a view cleared from its row', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs only the delete when the open view is deleted', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await chooseFromRowMenu('Errors', 'delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
|
||||
);
|
||||
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs a copied link', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'copyLink');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs edited view details', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'editDetails');
|
||||
await submitName(' renamed');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
SavedViewEvents.ViewDetailsEdited,
|
||||
{ source, viewId: errors.id },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log edited view details that fail to save', async () => {
|
||||
mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'editDetails');
|
||||
await submitName(' renamed');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewDetailsEdited)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not log a delete that fails', async () => {
|
||||
mockSavedViewsApi([errors], { deleteStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewDeleted)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,15 @@ export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
updateStatus = 204,
|
||||
deleteStatus = 204,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
listFailures = 0,
|
||||
}: {
|
||||
createStatus?: number;
|
||||
updateStatus?: number;
|
||||
deleteStatus?: number;
|
||||
createdId?: string;
|
||||
getDelay?: number;
|
||||
listFailures?: number;
|
||||
@@ -85,20 +89,38 @@ export function mockSavedViewsApi(
|
||||
const body = await req.json();
|
||||
const id = String(req.params.id);
|
||||
requests.updated.push({ id, body });
|
||||
if (updateStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(updateStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views[index] = { ...views[index], spec: body.spec };
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
return res(ctx.status(updateStatus));
|
||||
}),
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
const id = String(req.params.id);
|
||||
requests.deleted.push(id);
|
||||
if (deleteStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(deleteStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views.splice(index, 1);
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
return res(ctx.status(deleteStatus));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
|
||||
23
frontend/src/container/SavedViews/events.ts
Normal file
23
frontend/src/container/SavedViews/events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export enum SavedViewEvents {
|
||||
ViewCreated = 'Saved Views: View created',
|
||||
ViewUpdated = 'Saved Views: View updated',
|
||||
ViewDetailsEdited = 'Saved Views: View details edited',
|
||||
ViewDeleted = 'Saved Views: View deleted',
|
||||
LinkCopied = 'Saved Views: Link copied',
|
||||
ViewCleared = 'Saved Views: View cleared',
|
||||
ChangesDiscarded = 'Saved Views: Changes discarded',
|
||||
}
|
||||
|
||||
export enum SavedViewEventKeys {
|
||||
// Injected on every event by useSavedViewLogEvent
|
||||
Source = 'source',
|
||||
|
||||
ViewId = 'viewId',
|
||||
From = 'from',
|
||||
}
|
||||
|
||||
export enum SavedViewCreatedFrom {
|
||||
Header = 'header',
|
||||
SaveAsNew = 'save_as_new',
|
||||
EmptyState = 'empty_state',
|
||||
}
|
||||
@@ -2,14 +2,15 @@ import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
|
||||
import { SavedViewCreatedFrom } from '../events';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
|
||||
export function useSaveNewView(
|
||||
createView: UseSavedViewActionsResult['createView'],
|
||||
): (displayName: string) => Promise<boolean> {
|
||||
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
|
||||
if (!(await createView(displayName, from))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
@@ -30,12 +31,18 @@ import {
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
} from '../constants';
|
||||
import {
|
||||
SavedViewCreatedFrom,
|
||||
SavedViewEventKeys,
|
||||
SavedViewEvents,
|
||||
} from '../events';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
// Columns and formatting need no handling here: with a viewKey in the url the
|
||||
// preference provider reads them from the view, and without one it falls back
|
||||
@@ -50,6 +57,8 @@ export function useSavedViewActions(
|
||||
useQueryBuilder();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
@@ -107,13 +116,16 @@ export function useSavedViewActions(
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
// here; the time range and everything else a view does not set stays.
|
||||
const clearView = useCallback((): void => {
|
||||
const resetView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
@@ -121,8 +133,18 @@ export function useSavedViewActions(
|
||||
safeNavigate(`${pathname}?${params.toString()}`);
|
||||
}, [clearLastUsedView, pathname, safeNavigate]);
|
||||
|
||||
const clearView = useCallback((): void => {
|
||||
resetView();
|
||||
logSavedViewEvent(SavedViewEvents.ViewCleared, {
|
||||
[SavedViewEventKeys.ViewId]: viewKey,
|
||||
});
|
||||
}, [resetView, logSavedViewEvent, viewKey]);
|
||||
|
||||
const createView = useCallback(
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
async (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
@@ -137,6 +159,10 @@ export function useSavedViewActions(
|
||||
[QueryParams.viewKey]: data.id,
|
||||
});
|
||||
setLastUsedView(data.id, displayName);
|
||||
logSavedViewEvent(SavedViewEvents.ViewCreated, {
|
||||
[SavedViewEventKeys.ViewId]: data.id,
|
||||
[SavedViewEventKeys.From]: from,
|
||||
});
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -158,6 +184,7 @@ export function useSavedViewActions(
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
logSavedViewEvent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -173,6 +200,9 @@ export function useSavedViewActions(
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -185,13 +215,14 @@ export function useSavedViewActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
resetView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
|
||||
export type SavedViewLogEvent = (
|
||||
event: SavedViewEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useSavedViewLogEvent(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedViewLogEvent {
|
||||
return useCallback(
|
||||
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
|
||||
void logEvent(event, {
|
||||
[SavedViewEventKeys.Source]: source,
|
||||
...attributes,
|
||||
});
|
||||
},
|
||||
[source],
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
function showErrorToast(error: unknown, fallback: string): void {
|
||||
toast.error(
|
||||
@@ -42,6 +44,7 @@ export function useSavedViewRowActions(
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard],
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
@@ -83,13 +89,16 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not update the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient],
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
@@ -100,13 +109,16 @@ export function useSavedViewRowActions(
|
||||
await deleteSavedView({ pathParams: { id: view.id } });
|
||||
await invalidateSavedViews(queryClient, source);
|
||||
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not delete the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteSavedView, source, queryClient],
|
||||
[deleteSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
export type SavedViewOptions = Pick<
|
||||
@@ -51,7 +53,12 @@ export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
/** Clears the open view without logging it, for flows that are not a clear. */
|
||||
resetView: () => void;
|
||||
createView: (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
|
||||
) {
|
||||
@include custom-scrollbar;
|
||||
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Collapse } from 'antd';
|
||||
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../../AIThreadMessage/types';
|
||||
import MessagesSection from '../../SpanMessages/MessagesSection';
|
||||
import { RawMessagesValue } from '../../utils/spanMessages';
|
||||
import { ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
|
||||
@@ -20,7 +19,6 @@ interface MessagesAccordionProps extends Pick<
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
@@ -28,7 +26,6 @@ function MessagesAccordion({
|
||||
icon,
|
||||
title,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
@@ -36,10 +33,8 @@ function MessagesAccordion({
|
||||
}: MessagesAccordionProps): JSX.Element | null {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const canFormat = messages.length > 0;
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
if (!canFormat && !raw) {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -66,12 +61,12 @@ function MessagesAccordion({
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
extra: isOpen && canFormat && (
|
||||
extra: isOpen && (
|
||||
// The view toggle lives in the header; its clicks must not fold the panel.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span onClick={stopToggle}>
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
testId={`${testId}-view-toggle`}
|
||||
/>
|
||||
@@ -79,9 +74,8 @@ function MessagesAccordion({
|
||||
),
|
||||
children: (
|
||||
<MessagesSection
|
||||
view={effectiveView}
|
||||
view={view}
|
||||
messages={messages}
|
||||
raw={raw}
|
||||
testId={`${testId}-body`}
|
||||
messageType={messageType}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -95,8 +89,4 @@ function MessagesAccordion({
|
||||
);
|
||||
}
|
||||
|
||||
MessagesAccordion.defaultProps = {
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesAccordion;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import { MessageType } from '../../AIThreadMessage/types';
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
} from '../../utils/spanMessages';
|
||||
import { ThreadSpan } from '../../TraceDetailsThread/types';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
import MessagesAccordion from './MessagesAccordion';
|
||||
@@ -25,7 +28,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
if (!hasFormattedMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,7 +38,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
title="Input"
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
messageType={MessageType.Input}
|
||||
testId="span-ai-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -45,7 +47,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
icon={<ArrowDownRight size={14} />}
|
||||
title="Output"
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
messageType={MessageType.Output}
|
||||
testId="span-ai-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge (service name, status message).
|
||||
|
||||
@@ -3,8 +3,7 @@ import AIThreadMessage, {
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from '../utils/spanMessages';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
@@ -17,7 +16,6 @@ interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
@@ -25,21 +23,19 @@ function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
{view === ThreadView.Formatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
@@ -54,7 +50,7 @@ function MessagesSection({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
<JsonBlock data={messages} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -62,7 +58,6 @@ function MessagesSection({
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { RawMessagesValue } from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface RawMessagesProps {
|
||||
value: RawMessagesValue;
|
||||
}
|
||||
|
||||
function RawMessages({ value }: RawMessagesProps): JSX.Element {
|
||||
if (typeof value === 'object') {
|
||||
return <JsonBlock data={value} />;
|
||||
}
|
||||
return (
|
||||
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
|
||||
Unable to render messages
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default RawMessages;
|
||||
@@ -28,12 +28,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rawUnavailable {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.missing {
|
||||
padding: var(--spacing-10) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { MessageType } from '../AIThreadMessage/types';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
hasRawMessages,
|
||||
} from '../utils/spanMessages';
|
||||
import { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
@@ -32,9 +28,8 @@ function SpanMessages({
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
|
||||
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
|
||||
if (!hasFormattedMessages(data)) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.missing, className)}
|
||||
@@ -45,11 +40,11 @@ function SpanMessages({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFormatted) {
|
||||
if (view === ThreadView.Json) {
|
||||
return (
|
||||
<div className={cx(styles.root, className)} data-testid="span-messages-json">
|
||||
<JsonBlock
|
||||
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
|
||||
data={{ input: data.formattedInput, output: data.formattedOutput }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -60,7 +55,6 @@ function SpanMessages({
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
messageType={MessageType.Input}
|
||||
testId="span-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -69,7 +63,6 @@ function SpanMessages({
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
messageType={MessageType.Output}
|
||||
testId="span-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
|
||||
@@ -48,41 +48,36 @@ describe('SpanMessages', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state in JSON view when there are no raw messages', () => {
|
||||
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
|
||||
it('shows the empty state in JSON view when there are no formatted messages', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders raw messages in JSON view', () => {
|
||||
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
|
||||
formattedInput: [
|
||||
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
|
||||
],
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens every key in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/raw-text/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses tall JSON behind Show more in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
|
||||
{ role: 'user', content: 'hi' },
|
||||
]),
|
||||
},
|
||||
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
@@ -99,10 +99,12 @@ function TraceDetailsHeader({
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
if (hasInAppHistory()) {
|
||||
history.goBack();
|
||||
} else if (!isOverview) {
|
||||
history.push(ROUTES.AI_OBSERVABILITY_EXPLORER);
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
}, [isOverview]);
|
||||
|
||||
const handleToggleTraceDetails = useCallback((): void => {
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
|
||||
@@ -22,7 +22,8 @@ function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
|
||||
void setTab(value as TraceDetailsTab, { history: 'push' });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
import { useTraceSummary } from '../useTraceSummary';
|
||||
@@ -100,6 +98,22 @@ describe('TraceDetailsHeader – back button', () => {
|
||||
expect(mockPush).toHaveBeenCalledWith(ROUTES.TRACES_EXPLORER);
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes to the AI observability explorer when opened directly on another tab', async () => {
|
||||
mockHasInAppHistory.mockReturnValue(false);
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams="?tab=thread">
|
||||
<TraceDetailsHeader {...baseProps} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockPush).toHaveBeenCalledWith(ROUTES.AI_OBSERVABILITY_EXPLORER);
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – action cluster', () => {
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-20) var(--spacing-24);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 428px;
|
||||
padding: var(--spacing-16);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--paragraph-base-500-line-height);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 420px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
|
||||
|
||||
import styles from './ThreadNoAiSpans.module.scss';
|
||||
@@ -10,32 +11,28 @@ import styles from './ThreadNoAiSpans.module.scss';
|
||||
function ThreadNoAiSpans(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-empty">
|
||||
<div className={styles.message}>
|
||||
<span className={styles.emoji} aria-hidden>
|
||||
🚏
|
||||
</span>
|
||||
<div className={styles.paragraphs}>
|
||||
<Typography size="base" weight="medium" color="muted">
|
||||
Looks like this trace does not have any spans from AI-related actions.
|
||||
</Typography>
|
||||
<Typography size="base" weight="medium" className={styles.highlight}>
|
||||
Thread view summarising your entire AI-journey history will appear here
|
||||
when the spans have the necessary datapoints.
|
||||
</Typography>
|
||||
</div>
|
||||
<img src={noDataUrl} alt="" className={styles.noDataImg} />
|
||||
<span className={styles.title}>
|
||||
This trace has no spans from AI-related actions
|
||||
</span>
|
||||
<span className={styles.description}>
|
||||
Thread view summarising your entire AI-journey history will appear here when
|
||||
the spans have the necessary datapoints.
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
suffix={<ArrowRight size={12} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
|
||||
}}
|
||||
testId="thread-empty-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
suffix={<ArrowRight size={12} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
|
||||
}}
|
||||
testId="thread-empty-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ function TraceDetailsThread(): JSX.Element {
|
||||
spansAboveFirstPage,
|
||||
isLoading,
|
||||
isError,
|
||||
isNotFound,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
@@ -52,6 +53,7 @@ function TraceDetailsThread(): JSX.Element {
|
||||
interestedSpanId,
|
||||
spans,
|
||||
isLoading,
|
||||
isNotFound,
|
||||
);
|
||||
const interestedSpanIndex = spans.findIndex(
|
||||
(span) => span.span_id === interestedSpanId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { getTraceThread } from 'api/generated/services/tracedetail';
|
||||
import { act, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useTraceThread } from '../useTraceThread';
|
||||
|
||||
@@ -154,6 +154,7 @@ describe('useTraceThread', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockGetTraceThread).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isNotFound).toBe(true);
|
||||
});
|
||||
|
||||
it('retries other errors', async () => {
|
||||
@@ -162,5 +163,6 @@ describe('useTraceThread', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
|
||||
expect(result.current.isNotFound).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ interface UseTraceThreadResult {
|
||||
spansAboveFirstPage: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
/** The interested span (or trace) doesn't exist; an empty thread is not this. */
|
||||
isNotFound: boolean;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
@@ -99,6 +101,7 @@ export function useTraceThread({
|
||||
spansAboveFirstPage,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
isNotFound: isNotFoundError(query.error),
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
hasPreviousPage: !!query.hasPreviousPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
|
||||
@@ -28,6 +28,7 @@ export function getInterestedSpanStatus(
|
||||
interestedSpanId: string | undefined,
|
||||
spans: ThreadSpan[],
|
||||
isLoading: boolean,
|
||||
isNotFound: boolean,
|
||||
): InterestedSpanStatus {
|
||||
if (!interestedSpanId) {
|
||||
return InterestedSpanStatus.None;
|
||||
@@ -35,9 +36,13 @@ export function getInterestedSpanStatus(
|
||||
if (isLoading) {
|
||||
return InterestedSpanStatus.Loading;
|
||||
}
|
||||
if (spans.length === 0) {
|
||||
if (isNotFound) {
|
||||
return InterestedSpanStatus.NotFound;
|
||||
}
|
||||
// The span exists but the thread is empty, so the no-AI-spans state applies.
|
||||
if (spans.length === 0) {
|
||||
return InterestedSpanStatus.None;
|
||||
}
|
||||
return spans.some((span) => span.span_id === interestedSpanId)
|
||||
? InterestedSpanStatus.Found
|
||||
: InterestedSpanStatus.NoIOMessages;
|
||||
|
||||
@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
|
||||
}
|
||||
|
||||
export const LLM_OBSERVABILITY_DOCS_URL =
|
||||
'https://signoz.io/docs/llm-observability/';
|
||||
'https://signoz.io/docs/ai-observability/';
|
||||
|
||||
@@ -1,49 +1,19 @@
|
||||
import { parseJsonObject } from '../AIThreadMessage/utils';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadSpan } from '../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from './aiTraceUtils';
|
||||
|
||||
/** Parsed JSON, or the original string when it isn't JSON. */
|
||||
export type RawMessagesValue = object | string;
|
||||
|
||||
export interface SpanMessagesData {
|
||||
formattedInput: ThreadMessage[];
|
||||
formattedOutput: ThreadMessage[];
|
||||
rawInput?: RawMessagesValue;
|
||||
rawOutput?: RawMessagesValue;
|
||||
}
|
||||
|
||||
function toRawValue(value: unknown): RawMessagesValue | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return parseJsonObject(value) ?? value;
|
||||
}
|
||||
return typeof value === 'object' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
|
||||
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
|
||||
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
|
||||
|
||||
// Spans from before the JSON column have no formatted messages.
|
||||
return {
|
||||
formattedInput: span.formattedInput ?? [],
|
||||
formattedOutput: span.formattedOutput ?? [],
|
||||
rawInput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFormattedMessages(data: SpanMessagesData): boolean {
|
||||
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
|
||||
}
|
||||
|
||||
export function hasRawMessages(data: SpanMessagesData): boolean {
|
||||
return !!data.rawInput || !!data.rawOutput;
|
||||
}
|
||||
|
||||
export function hasAnyMessages(data: SpanMessagesData): boolean {
|
||||
return hasFormattedMessages(data) || hasRawMessages(data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { AI_OBSERVABILITY_URL_OPTIONS } from '../configs/aiObservabilityLoaderConfig';
|
||||
import { usePreferenceContext } from '../context/PreferenceContextProvider';
|
||||
|
||||
const SERVICE_NAME = {
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
} as TelemetryFieldKey;
|
||||
const SPAN_NAME = {
|
||||
name: 'name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
const LLM_CALL_COUNT = {
|
||||
name: 'llm_call_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
const options = (columns: unknown[]): string =>
|
||||
JSON.stringify({ selectColumns: columns });
|
||||
|
||||
const explorerSearch = (panelType: string, aiOptions?: string): string => {
|
||||
const params = new URLSearchParams({ panelTypes: JSON.stringify(panelType) });
|
||||
if (aiOptions) {
|
||||
params.set(AI_OBSERVABILITY_URL_OPTIONS, aiOptions);
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
const explorerRoute = (panelType: string, aiOptions?: string): string =>
|
||||
`${ROUTES.AI_OBSERVABILITY_EXPLORER}${explorerSearch(panelType, aiOptions)}`;
|
||||
|
||||
/** The `aiOptions` value of the last url the slice navigated to. */
|
||||
const lastNavigatedOptions = (): string | null => {
|
||||
const [to] =
|
||||
safeNavigateMock.mock.calls[safeNavigateMock.mock.calls.length - 1];
|
||||
return new URL(to as string, 'http://localhost').searchParams.get(
|
||||
AI_OBSERVABILITY_URL_OPTIONS,
|
||||
);
|
||||
};
|
||||
|
||||
const names = (columns: TelemetryFieldKey[] | null): string =>
|
||||
columns ? columns.map((c) => c.name).join(',') : 'none';
|
||||
|
||||
function Consumer(): JSX.Element {
|
||||
const {
|
||||
aiObservability: { columns, updateColumns },
|
||||
} = usePreferenceContext();
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="list">{names(columns.list)}</div>
|
||||
<div data-testid="trace">{names(columns.trace)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
updateColumns('list', [SPAN_NAME, LLM_CALL_COUNT, SPAN_NAME])
|
||||
}
|
||||
>
|
||||
set list
|
||||
</button>
|
||||
<button type="button" onClick={(): void => updateColumns('list', [])}>
|
||||
clear list
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => history.push(explorerSearch('trace'))}
|
||||
>
|
||||
to trace
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AI observability preferences', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads each tab from localStorage and mirrors the active tab into the url', async () => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS,
|
||||
options([SERVICE_NAME]),
|
||||
);
|
||||
|
||||
render(<Consumer />, undefined, { initialRoute: explorerRoute('list') });
|
||||
|
||||
expect(screen.getByTestId('list')).toHaveTextContent('service.name');
|
||||
expect(screen.getByTestId('trace')).toHaveTextContent('none');
|
||||
await waitFor(() => expect(safeNavigateMock).toHaveBeenCalled());
|
||||
expect(lastNavigatedOptions()).toBe(options([SERVICE_NAME]));
|
||||
expect(safeNavigateMock).toHaveBeenLastCalledWith(expect.any(String), {
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the url for the active tab and sanitizes it', async () => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
|
||||
options([SERVICE_NAME]),
|
||||
);
|
||||
const fromUrl = options([
|
||||
{ key: 'llm_call_count', signal: 'traces', fieldContext: 'trace' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
LLM_CALL_COUNT,
|
||||
]);
|
||||
|
||||
render(<Consumer />, undefined, {
|
||||
initialRoute: explorerRoute('trace', fromUrl),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('trace')).toHaveTextContent(/^llm_call_count$/),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops trace aggregates and duplicates from list columns, and resets on an empty selection', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<Consumer />, undefined, { initialRoute: explorerRoute('list') });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'set list' }));
|
||||
expect(screen.getByTestId('list')).toHaveTextContent(/^name$/);
|
||||
expect(getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS)).toBe(
|
||||
options([SPAN_NAME]),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'clear list' }));
|
||||
expect(screen.getByTestId('list')).toHaveTextContent('none');
|
||||
expect(
|
||||
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites the url param per tab so a tab never reads another tab’s columns', async () => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
|
||||
options([LLM_CALL_COUNT]),
|
||||
);
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<Consumer />, undefined, {
|
||||
initialRoute: explorerRoute('list', options([SERVICE_NAME])),
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'to trace' }));
|
||||
await waitFor(() =>
|
||||
expect(lastNavigatedOptions()).toBe(options([LLM_CALL_COUNT])),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the param on tabs without columns and leaves other pages alone', async () => {
|
||||
const { unmount } = render(<Consumer />, undefined, {
|
||||
initialRoute: explorerRoute('graph', options([SERVICE_NAME])),
|
||||
});
|
||||
await waitFor(() => expect(safeNavigateMock).toHaveBeenCalled());
|
||||
expect(lastNavigatedOptions()).toBeNull();
|
||||
unmount();
|
||||
safeNavigateMock.mockClear();
|
||||
|
||||
render(<Consumer />, undefined, {
|
||||
initialRoute: `${ROUTES.TRACES_EXPLORER}?${AI_OBSERVABILITY_URL_OPTIONS}=x`,
|
||||
});
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEFAULT_PANEL_TYPE } from 'container/LLMObservability/Explorer/constants';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
|
||||
import { AIObservabilityColumnsView } from '../types';
|
||||
|
||||
// Not `options`: the Traces/Logs preferences and ExplorerCard already read and write that param.
|
||||
export const AI_OBSERVABILITY_URL_OPTIONS = 'aiOptions';
|
||||
|
||||
const STORAGE_KEYS: Record<AIObservabilityColumnsView, LOCALSTORAGE> = {
|
||||
list: LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS,
|
||||
trace: LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
|
||||
};
|
||||
|
||||
const PANEL_TYPE_TO_VIEW: Partial<
|
||||
Record<PANEL_TYPES, AIObservabilityColumnsView>
|
||||
> = {
|
||||
[PANEL_TYPES.LIST]: 'list',
|
||||
[PANEL_TYPES.TRACE]: 'trace',
|
||||
};
|
||||
|
||||
/** The tab whose columns the url param carries; `null` for tabs without columns. */
|
||||
export const getActiveColumnsView = (
|
||||
search: string,
|
||||
): AIObservabilityColumnsView | null => {
|
||||
const raw = new URLSearchParams(search).get(QueryParams.panelTypes);
|
||||
try {
|
||||
const panelType = raw ? (JSON.parse(raw) as PANEL_TYPES) : DEFAULT_PANEL_TYPE;
|
||||
return PANEL_TYPE_TO_VIEW[panelType] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type RawColumn = Partial<TelemetryFieldKey> & { key?: string };
|
||||
|
||||
/** Drops malformed, non-trace and duplicate fields; `null` when nothing usable is left. */
|
||||
export const sanitizeColumns = (
|
||||
view: AIObservabilityColumnsView,
|
||||
columns: unknown,
|
||||
): TelemetryFieldKey[] | null => {
|
||||
if (!Array.isArray(columns)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const sanitized = (columns as RawColumn[]).reduce<TelemetryFieldKey[]>(
|
||||
(acc, column) => {
|
||||
const name = column?.name ?? column?.key;
|
||||
if (typeof name !== 'string' || !name) {
|
||||
return acc;
|
||||
}
|
||||
if (column.signal && column.signal !== 'traces') {
|
||||
return acc;
|
||||
}
|
||||
// Per-trace aggregates have no value on a span row.
|
||||
if (view === 'list' && column.fieldContext === 'trace') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const id = buildCompositeKey(
|
||||
name,
|
||||
column.fieldContext,
|
||||
column.fieldDataType,
|
||||
);
|
||||
if (seen.has(id)) {
|
||||
return acc;
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const { key: _key, ...field } = column;
|
||||
acc.push({ ...field, name } as TelemetryFieldKey);
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return sanitized.length ? sanitized : null;
|
||||
};
|
||||
|
||||
const parseSelectColumns = (raw: string | null): unknown => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)?.selectColumns;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeColumns = (columns: TelemetryFieldKey[]): string =>
|
||||
JSON.stringify({ selectColumns: columns });
|
||||
|
||||
export const loadLocalColumns = (
|
||||
view: AIObservabilityColumnsView,
|
||||
): TelemetryFieldKey[] | null =>
|
||||
sanitizeColumns(
|
||||
view,
|
||||
parseSelectColumns(getLocalStorageKey(STORAGE_KEYS[view])),
|
||||
);
|
||||
|
||||
export const loadUrlColumns = (
|
||||
view: AIObservabilityColumnsView,
|
||||
search: string,
|
||||
): TelemetryFieldKey[] | null =>
|
||||
sanitizeColumns(
|
||||
view,
|
||||
parseSelectColumns(
|
||||
new URLSearchParams(search).get(AI_OBSERVABILITY_URL_OPTIONS),
|
||||
),
|
||||
);
|
||||
|
||||
export const saveLocalColumns = (
|
||||
view: AIObservabilityColumnsView,
|
||||
columns: TelemetryFieldKey[] | null,
|
||||
): void => {
|
||||
if (columns) {
|
||||
setLocalStorageKey(STORAGE_KEYS[view], serializeColumns(columns));
|
||||
} else {
|
||||
removeLocalStorageKey(STORAGE_KEYS[view]);
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'providers/preferences/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useAIObservabilityPreferenceSync } from '../sync/useAIObservabilityPreferenceSync';
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
|
||||
const PreferenceContext = createContext<PreferenceContextValue | undefined>(
|
||||
@@ -47,12 +48,15 @@ export function PreferenceContextProvider({
|
||||
dataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
const aiObservabilitySlice = useAIObservabilityPreferenceSync();
|
||||
|
||||
const value = useMemo<PreferenceContextValue>(
|
||||
() => ({
|
||||
logs: logsSlice,
|
||||
traces: tracesSlice,
|
||||
aiObservability: aiObservabilitySlice,
|
||||
}),
|
||||
[logsSlice, tracesSlice],
|
||||
[logsSlice, tracesSlice, aiObservabilitySlice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import {
|
||||
AI_OBSERVABILITY_URL_OPTIONS,
|
||||
getActiveColumnsView,
|
||||
loadLocalColumns,
|
||||
loadUrlColumns,
|
||||
sanitizeColumns,
|
||||
saveLocalColumns,
|
||||
serializeColumns,
|
||||
} from '../configs/aiObservabilityLoaderConfig';
|
||||
import {
|
||||
AIObservabilityColumns,
|
||||
AIObservabilityColumnsView,
|
||||
AIObservabilityPreferenceSlice,
|
||||
} from '../types';
|
||||
|
||||
export function useAIObservabilityPreferenceSync(): AIObservabilityPreferenceSlice {
|
||||
const { pathname, search } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isExplorer = pathname === ROUTES.AI_OBSERVABILITY_EXPLORER;
|
||||
|
||||
const [columns, setColumns] = useState<AIObservabilityColumns>(() => ({
|
||||
list: loadLocalColumns('list'),
|
||||
trace: loadLocalColumns('trace'),
|
||||
}));
|
||||
|
||||
// The url is read once per explorer visit; after that the slice owns the param.
|
||||
const hasReadUrl = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExplorer) {
|
||||
hasReadUrl.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const view = getActiveColumnsView(search);
|
||||
|
||||
if (!hasReadUrl.current) {
|
||||
hasReadUrl.current = true;
|
||||
const urlColumns = view ? loadUrlColumns(view, search) : null;
|
||||
if (view && urlColumns) {
|
||||
setColumns((prev) => ({ ...prev, [view]: urlColumns }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const viewColumns = view ? columns[view] : null;
|
||||
const desired = viewColumns ? serializeColumns(viewColumns) : null;
|
||||
const params = new URLSearchParams(search);
|
||||
if (params.get(AI_OBSERVABILITY_URL_OPTIONS) === desired) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (desired) {
|
||||
params.set(AI_OBSERVABILITY_URL_OPTIONS, desired);
|
||||
} else {
|
||||
params.delete(AI_OBSERVABILITY_URL_OPTIONS);
|
||||
}
|
||||
safeNavigate(`${pathname}?${params.toString()}`, { replace: true });
|
||||
}, [isExplorer, pathname, search, columns, safeNavigate]);
|
||||
|
||||
const updateColumns = useCallback(
|
||||
(view: AIObservabilityColumnsView, newColumns: TelemetryFieldKey[]): void => {
|
||||
// An emptied selection falls back to the view's defaults.
|
||||
const sanitized = sanitizeColumns(view, newColumns);
|
||||
saveLocalColumns(view, sanitized);
|
||||
setColumns((prev) => ({ ...prev, [view]: sanitized }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ columns, updateColumns }), [columns, updateColumns]);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
export enum PreferenceMode {
|
||||
SAVED_VIEW = 'savedView',
|
||||
@@ -15,9 +14,26 @@ export interface PreferenceSlice {
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
}
|
||||
|
||||
export type AIObservabilityColumnsView = 'list' | 'trace';
|
||||
|
||||
/** `null` means no stored preference; the view applies its own defaults. */
|
||||
export type AIObservabilityColumns = Record<
|
||||
AIObservabilityColumnsView,
|
||||
TelemetryFieldKey[] | null
|
||||
>;
|
||||
|
||||
export interface AIObservabilityPreferenceSlice {
|
||||
columns: AIObservabilityColumns;
|
||||
updateColumns: (
|
||||
view: AIObservabilityColumnsView,
|
||||
newColumns: TelemetryFieldKey[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface PreferenceContextValue {
|
||||
logs: PreferenceSlice;
|
||||
traces: PreferenceSlice;
|
||||
aiObservability: AIObservabilityPreferenceSlice;
|
||||
}
|
||||
|
||||
export interface FormattingOptions {
|
||||
|
||||
Reference in New Issue
Block a user